diff --git a/.changeset/many-poems-count.md b/.changeset/many-poems-count.md new file mode 100644 index 0000000..4284a95 --- /dev/null +++ b/.changeset/many-poems-count.md @@ -0,0 +1,5 @@ +--- +'jotai-logger': minor +--- + +fix: retroactively set dependents and pendingPromises on initialized/mounted events diff --git a/.changeset/neat-pens-appear.md b/.changeset/neat-pens-appear.md new file mode 100644 index 0000000..c36bdb8 --- /dev/null +++ b/.changeset/neat-pens-appear.md @@ -0,0 +1,19 @@ +--- +'jotai-logger': major +--- + +Split the package into vanilla/react/formatter entry points + +- Split the core logger from its console formatter. +- The core handle only scheduling and filtering options and accepts a new `formatter` option. +- The new `consoleFormatter` factory creates the built in console formatter and accepts the old display options (`domain`, `logger`, `colorScheme`, etc.). +- Add the `formatter` option to `bindAtomsLoggerToStore` and `useAtomsLogger` to replace the default console output with any custom function. + +BREAKING CHANGE: + +Formatting options that were present in `bindAtomsLoggerToStore` and `useAtomsLogger` are now moved to the new `consoleFormatter` factory options. + +```diff +- bindAtomsLoggerToStore(store, { stringifyLimit: 100 }); ++ const loggedStore = createLoggedStore(store, { formatter: consoleFormatter({ stringifyLimit: 100 }) }); +``` diff --git a/.changeset/olive-toes-lay.md b/.changeset/olive-toes-lay.md new file mode 100644 index 0000000..a97f159 --- /dev/null +++ b/.changeset/olive-toes-lay.md @@ -0,0 +1,5 @@ +--- +'jotai-logger': patch +--- + +fix: track dependencies that have the same name diff --git a/.changeset/pretty-signs-stick.md b/.changeset/pretty-signs-stick.md new file mode 100644 index 0000000..ef84f6b --- /dev/null +++ b/.changeset/pretty-signs-stick.md @@ -0,0 +1,5 @@ +--- +'jotai-logger': patch +--- + +fix: don't add empty or undefined data to events diff --git a/.changeset/real-cars-nail.md b/.changeset/real-cars-nail.md new file mode 100644 index 0000000..e4cab6b --- /dev/null +++ b/.changeset/real-cars-nail.md @@ -0,0 +1,12 @@ +--- +'jotai-logger': major +--- + +Jotai 2.20 support + +Adds support for Jotai 2.20's new internal `INTERNAL_buildStoreRev3` API +(see [pmndrs/jotai#3293](https://github.com/pmndrs/jotai/pull/3293)). + +BREAKING CHANGE: + +Only jotai 2.20.0 and up is supported due to changes in their internal APIs. diff --git a/.changeset/sixty-sheep-fetch.md b/.changeset/sixty-sheep-fetch.md new file mode 100644 index 0000000..e200fb0 --- /dev/null +++ b/.changeset/sixty-sheep-fetch.md @@ -0,0 +1,18 @@ +--- +'jotai-logger': major +--- + +Replace the mutation-based API with a derived-store API + +- The store is no longer mutated. Instead, `createLoggedStore` returns a **new** store that shares all internal state with the parent but intercepts `get`, `set` and `sub` for logging. +- On the React side, `AtomLoggerProvider` propagates the logged store to children via a Jotai ``, retrieving the parent store from context automatically. +- This approach aligns with Jotai's internal `INTERNAL_buildStoreRev2` API introduced in jotai v2.15 (see https://github.com/pmndrs/jotai/pull/3149). + +BREAKING CHANGE: + +A migration guide from v4 to v5 is present in the README. [See link](https://github.com/Wendystraite/jotai-logger#migration-guide). TLDR: + +- `useAtomsLogger` is replaced by `AtomLoggerProvider`, a Provider-like component that automatically picks up the nearest Jotai store from context and wraps children in a new logged store. +- `bindAtomsLoggerToStore` is replaced by `createLoggedStore` that creates and return a new store. +- `isAtomsLoggerBoundToStore` removed → use `isLoggedStore` +- `createLoggedStore` **throws** instead of returning `false` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34c95ea..7d26cb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,5 @@ jobs: uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + + - run: pnpm exec pkg-pr-new publish --commentWithSha diff --git a/README.md b/README.md index d273d03..dcabe25 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Codecov](https://img.shields.io/codecov/c/gh/Wendystraite/jotai-logger)](https://app.codecov.io/gh/Wendystraite/jotai-logger) [![npm bundle size](https://img.shields.io/bundlephobia/minzip/jotai-logger)](https://bundlephobia.com/package/jotai-logger) [![GitHub License](https://img.shields.io/github/license/Wendystraite/jotai-logger)](https://github.com/Wendystraite/jotai-logger/blob/main/LICENSE.md) +[![pkg.pr.new](https://pkg.pr.new/badge/Wendystraite/jotai-logger)](https://pkg.pr.new/~/Wendystraite/jotai-logger) Logging utility for [Jotai](https://github.com/pmndrs/jotai) that helps you debug and track atom state changes. @@ -20,6 +21,7 @@ Logging utility for [Jotai](https://github.com/pmndrs/jotai) that helps you debu - 🐞 Compatible with [jotai-devtools](https://github.com/jotaijs/jotai-devtools) - 📦 No dependencies, lightweight and tree-shakable - 🎯 Support for both React hooks and vanilla store API +- 🔌 Pluggable formatter system with built-in console output ## Installation @@ -36,228 +38,204 @@ pnpm install jotai-logger ## Compatibility -ESM Only. +ESM Only. Compatible with React 17+ and Jotai 2.20+. +See the table below for older Jotai versions. + +
+Version compatibility reference | jotai-logger | [react](https://github.com/facebook/react) | [jotai](https://github.com/pmndrs/jotai) | [jotai-devtools](https://github.com/jotaijs/jotai-devtools) | | ------------ | ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------- | | <= 2.5.2 | >=17.0.0 | >= 2.12.4 < 2.14.0 | == 0.12.0 | | >= 3.0.0 | >=17.0.0 | >= 2.14.0 < 2.18.0 | >= 0.13.0 | -| >= 4.0.0 | >=17.0.0 | >= 2.18.0 | >= 0.13.0 | +| >= 4.0.0 | >=17.0.0 | >= 2.18.0 < 2.20.0 | >= 0.13.0 | +| >= 5.0.0 | >=17.0.0 | >= 2.20.0 | >= 0.14.0 | + +
## Usage -### Basic Setup +
+React Setup ```tsx -import { useAtomsLogger } from 'jotai-logger'; +import { Provider } from 'jotai'; +import { AtomLoggerProvider } from 'jotai-logger'; function App() { return ( - <> - - {/* your app */} - + + + + + ); } - -function AtomsLogger() { - useAtomsLogger(); - return null; -} ``` -### Vanilla Setup +
+ +
+Vanilla Setup ```ts import { createStore } from 'jotai'; -import { bindAtomsLoggerToStore } from 'jotai-logger'; +import { createLoggedStore } from 'jotai-logger/vanilla'; -const store = createStore(); -bindAtomsLoggerToStore(store); +const parentStore = createStore(); +const store = createLoggedStore(parentStore, options); ``` -## Configuration Options +
-You can customize the logger with various options: +## Logger Configuration -```tsx -import { AtomsLoggerOptions } from 'jotai-logger'; +Options passed to `createLoggedStore` / `AtomLoggerProvider` via `AtomLoggerOptions`. +These control event collection and transaction scheduling only. -const options: AtomsLoggerOptions = { - enabled: true, - domain: 'MyApp', - showPrivateAtoms: false, - // Add other options as needed -}; +
+AtomLoggerOptions reference -useAtomsLogger(options); -// or -bindAtomsLoggerToStore(store, options); -``` +### AtomLoggerOptions reference -### Options +```ts +import type { AtomLoggerOptions } from 'jotai-logger/vanilla'; -You can customize the logger with various options: +type AtomLoggerOptions = { + /** Custom formatter called for each completed transaction. Defaults to consoleFormatter(). */ + formatter?: AtomLoggerFormatter; -```tsx -type AtomsLoggerOptions = { - /** Enable or disable the logger (default: true) */ + /** Enable or disable the logger. @default true */ enabled?: boolean; - /** Domain identifier for the logger in console output */ - domain?: string; - /** Whether to show private atoms used internally by Jotai (default: false) */ + + /** Show private atoms used internally by Jotai libraries. @default false */ shouldShowPrivateAtoms?: boolean; - /** Custom function to determine which atoms to show */ + + /** Custom predicate to filter which atoms are logged. */ shouldShowAtom?: (atom: Atom) => boolean; - /** Custom logger to use instead of console */ - logger?: Logger; - /** Whether to group transaction logs with logger.group (default: true) */ - groupTransactions?: boolean; - /** Whether to group event logs with logger.group (default: false) */ - groupEvents?: boolean; - /** Number of spaces for each indentation level (default: 0) */ - indentSpaces?: number; - /** Whether to use colors/formatting in the console (default: true) */ - formattedOutput?: boolean; - /** Color scheme to use: 'default', 'light', or 'dark' (default: 'default') */ - colorScheme?: 'default' | 'light' | 'dark'; - /** Maximum length of stringified data (default: 50) */ - stringifyLimit?: number; - /** Whether to stringify data in the logs (default: true) */ - stringifyValues?: boolean; - /** Custom function to stringify data in the logs (default: `toString` and `JSON.stringify`) */ - stringify?: (value: unknown) => string; - /** Whether to show transaction numbers (default: true) */ - showTransactionNumber?: boolean; - /** Whether to show events count in transactions (default: true) */ - showTransactionEventsCount?: boolean; - /** Whether to show transaction timestamps (default: false) */ - showTransactionLocaleTime?: boolean; - /** Whether to show elapsed time (default: true) */ - showTransactionElapsedTime?: boolean; - /** Whether to automatically align transaction components for better readability (default: true) */ - autoAlignTransactions?: boolean; - /** Whether to collapse transaction logs (default: true) */ - collapseTransactions?: boolean; - /** Whether to collapse event logs (default: false) */ - collapseEvents?: boolean; - /** Maximum number of owner stack entries to show (default: 2) */ - ownerStackLimit?: number; - /** Custom function to retrieve the React component stack that triggered the transaction */ + + /** (Experimental) Retrieve the React component owner stack for a transaction. */ getOwnerStack?: () => string | null | undefined; - /** Custom function to retrieve the current React component's display name */ + + /** (Experimental) Retrieve the currently rendering React component's display name. */ getComponentDisplayName?: () => string | undefined; - /** Whether to log synchronously or asynchronously (default: false) */ + + /** Log synchronously instead of asynchronously. @default false */ synchronous?: boolean; - /** Debounce time in milliseconds for grouping transactions (default: 250ms) */ + + /** Debounce period for grouping events into a single transaction (ms). @default 250 */ transactionDebounceMs?: number; - /** Timeout in milliseconds for requestIdleCallback (default: 250ms) */ + + /** Maximum timeout for requestIdleCallback scheduling (ms). @default 250 */ requestIdleCallbackTimeoutMs?: number; - /** Maximum processing time per batch in milliseconds (default: 16ms) */ - maxProcessingTimeMs?: number; -}; -const options: AtomsLoggerOptions = { - enabled: true, - domain: 'MyApp', - shouldShowPrivateAtoms: false, - // Add other options as needed + /** Maximum processing time per batch (ms). @default 16 */ + maxProcessingTimeMs?: number; }; - -useAtomsLogger(options); -// or -bindAtomsLoggerToStore(store, options); ``` -### Colors +
-The default color scheme uses colors that are easy to read in both light and dark mode. -The colors are from the colorblind-friendly palette known as the [Okabe-Ito color palette](https://siegal.bio.nyu.edu/color-palette/). +
+Changing options at runtime -The `colorScheme` option slightly changes the color palette contrast ratio to respect WCAG AA for normal text with a minimum contrast of 5:1 on a white (`#ffffff`) or dark (`#282828`) background. +### Changing options at runtime -See example bellow if you want the colors to be automatically determined based on the user's system preference using `window.matchMedia` : +You can change logger options at runtime by mutating the options object passed to `createLoggedStore` or `AtomLoggerProvider`: ```ts -// If you want the colors to be automatically determined based on the user's system preference -useAtomsLogger({ - colorScheme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', -}); - -// If you want the color to be specified in an environment variable (in vite) -useAtomsLogger({ colorScheme: import.meta.env.VITE_ATOMS_LOGGER_COLOR_SCHEME }); +const options: AtomLoggerOptions = { enabled: true }; +const store = createLoggedStore(parentStore, options); -// If you want to disable colors -useAtomsLogger({ formattedOutput: false }); +// Change options at runtime +options.enabled = false; ``` -### Stringification +Alternatively, you can access the logger options from the store state: -By default, the logger converts atom values to strings for console output using a combination of `toString` and `JSON.stringify`. +```ts +import { getLoggedStoreOptions } from 'jotai-logger/vanilla'; -You can control how values appear in logs with these options: +const store = createLoggedStore(parentStore, { enabled: true }); -- `stringifyValues`: Enable/disable string conversion (default: `true`) -- `stringifyLimit`: Maximum length for stringified output (default: `50`) -- `stringify`: Custom function for more advanced formatting +// Change options at runtime +getLoggedStoreOptions(store)!.enabled = false; +``` -For better formatting of complex objects, you can use libraries like [@vitest/pretty-format](https://www.npmjs.com/package/@vitest/pretty-format) or [pretty-format](https://www.npmjs.com/package/pretty-format): +
-```tsx -import { format as prettyFormat } from '@vitest/pretty-format'; -import { useAtomsLogger } from 'jotai-logger'; - -useAtomsLogger({ - stringifyValues: true, - stringifyLimit: 0, - stringify(value) { - return prettyFormat(value, { - min: true, - maxDepth: 3, - maxWidth: 5, - // See options in https://github.com/jestjs/jest/tree/main/packages/pretty-format#usage-with-options - }); - }, -}); -``` +
+Component Tracking — getOwnerStack & getComponentDisplayName (Experimental) ### Component Tracking (Experimental) -These are experimental features designed for React applications that may not work in all cases. - -#### Owner Stack Tracking (`getOwnerStack`) +These features are designed for React and may not work in all cases. -This feature allows the logger to track the React component hierarchy that triggered a transaction. When provided, the logger will display the parent components in the logs to help identify where state changes originate. +#### Owner Stack (`getOwnerStack`) -It accepts React 19.1+'s [`captureOwnerStack`](https://react.dev/reference/react/captureOwnerStack) function to retrieve the component stack. +Displays the React component hierarchy that triggered a transaction. +Accepts React 19.1+'s [`captureOwnerStack`](https://react.dev/reference/react/captureOwnerStack). ```tsx -import { useAtomsLogger } from 'jotai-logger'; +import { createLoggedStore } from 'jotai-logger/vanilla'; import { captureOwnerStack } from 'react'; -// React 19.1+ +createLoggedStore(parentStore, { + getOwnerStack: captureOwnerStack, +}); +``` -useAtomsLogger({ +The number of parent components shown is controlled by `ownerStackLimit` in `consoleFormatter` (default: `2`). + +```tsx +import { consoleFormatter } from 'jotai-logger/formatters/console'; +import { createLoggedStore } from 'jotai-logger/vanilla'; +import { captureOwnerStack } from 'react'; + +createLoggedStore(parentStore, { getOwnerStack: captureOwnerStack, + formatter: consoleFormatter({ ownerStackLimit: 5 }), }); ``` -The logger displays up to `ownerStackLimit` parent components. +Internal utility function that parses a stack trace from `captureOwnerStack` or any other source: + +```ts +/** + * Parse a trace from {@link https://react.dev/reference/react/captureOwnerStack | captureOwnerStack} (React 19.1+) or any other source. + * @see {@link https://github.com/Wendystraite/jotai-logger#owner-stack-getownerstack | Jotai Logger Owner Stack Tracking} + * @see {@link https://github.com/Wendystraite/jotai-logger/blob/main/src/utils/parse-owner-stack.ts | Jotai Logger parseOwnerStack utility function} + */ +function parseOwnerStack(stack: string | null | undefined): string[] { + return (stack ?? '') + .split('\n') + .map((line) => /^\s*at\s+([^\s]+)\s+/.exec(line)?.[1]) + .filter((c) => typeof c === 'string'); +} +``` #### Component Display Name (`getComponentDisplayName`) -This feature shows the current React component's display name in transaction logs. It's particularly useful when combined with owner stack tracking. +Shows the current component's display name in transaction logs. +If it is already shown at the end of the owner stack, it won't be duplicated. + +```tsx +import { createLoggedStore } from 'jotai-logger/vanilla'; + +createLoggedStore(parentStore, { + getComponentDisplayName: getReact19ComponentDisplayName, +}); +``` + +
+React 19+ implementation of getReact19ComponentDisplayName ```tsx -import React, { useAtomsLogger } from 'jotai-logger'; +import React from 'react'; /** - * Get the current React component's display name using React 19 internals. - * - * This only works when used directly within a React component's render - * and will not work in other lifecycle methods like useEffect or event handlers. - * - * This is an experimental feature and may break in future React versions. + * Get the currently rendering React component's display name using React 19's internal APIs. + * @see {@link https://github.com/Wendystraite/jotai-logger#component-display-name-getcomponentdisplayname | Jotai Logger Component Display Name} */ function getReact19ComponentDisplayName(): string | undefined { const React19 = React as { @@ -274,146 +252,447 @@ function getReact19ComponentDisplayName(): string | undefined { )?.A?.getOwner?.().type; return component?.displayName ?? component?.name; } - -useAtomsLogger({ - getComponentDisplayName: getReact19ComponentDisplayName, -}); ``` -If the component display name is already shown at the end of the owner stack, it won't be duplicated. +
-### Synchronous vs. Asynchronous Logging +
-By default, the logger uses an asynchronous approach to log transactions, ensuring minimal impact on your application's performance. +
+Synchronous vs. Asynchronous Logging -#### Synchronous Logging +### Synchronous vs. Asynchronous Logging -You can switch to synchronous logging by setting the `synchronous` option to `true`. +By default the logger uses asynchronous logging to minimise performance impact. -This option can be useful for debugging, testing, when you need deterministic log ordering or when you use your own logger that already logs asynchronously. -However, it can significantly impact performance, especially in applications with frequent atom changes. +#### Synchronous ```tsx -import { useAtomsLogger } from 'jotai-logger'; - -// Log transactions synchronously -useAtomsLogger({ - synchronous: true, -}); +createLoggedStore(parentStore, { synchronous: true }); ``` -#### Asynchronous Logging Configuration - -For asynchronous logging, you can fine-tune three key parameters: +Useful for debugging, testing, or deterministic log ordering. +Has a performance cost with frequent atom changes. -1. `transactionDebounceMs` (default: `250ms`) - Controls how transactions are grouped: - - Higher values (e.g., `500ms`) - Group more unknown events into fewer transactions, reducing console noise - - Lower values (e.g., `50ms`) - See transactions more quickly, with less grouping - - Setting to `0` - Schedule each transaction to be logged immediately without debouncing incoming events. This is the same as `synchronous: true`. +#### Asynchronous pipeline -2. `requestIdleCallbackTimeoutMs` (default: `250ms`) - Controls when scheduled transaction are written: - - Higher values - Allow more time for the browser to process logs during idle periods - - Setting to `0` - Only log when the browser is completely idle (may delay logs indefinitely) - - Setting to `-1` - Disable `requestIdleCallback` completely, logging scheduled transactions immediately. This is the same as `synchronous: true`. +Three parameters control the async pipeline: -3. `maxProcessingTimeMs` (default: `16ms`) - Controls how long to process transactions in a single batch: - - Higher values (e.g., `50ms`) - Process more transactions per batch, potentially improving throughput but may impact UI responsiveness - - Lower values (e.g., `5ms`) - Process fewer transactions per batch, keeping the main thread more responsive - - Setting to `0` or negative - Process all queued transactions in one go without time limits (same as `synchronous: true`) - - The default `16ms` corresponds to approximately one frame at 60fps, balancing performance and responsiveness - -Here are some examples of how to configure these options based on your needs: +1. `transactionDebounceMs` (default: `250ms`) — groups events into transactions: + - Higher → fewer, noisier transactions + - `0` → immediate scheduling (equivalent to `synchronous: true`) +2. `requestIdleCallbackTimeoutMs` (default: `250ms`) — schedules when logs are written: + - `0` → only write when truly idle (may delay indefinitely) + - `-1` → disable idle scheduling entirely (equivalent to `synchronous: true`) +3. `maxProcessingTimeMs` (default: `16ms`) — caps time per processing batch: + - `0` or negative → process everything in one go (equivalent to `synchronous: true`) + - `16ms` ≈ one frame at 60fps ```tsx -// Quick feedback: minimal debounce, guaranteed logging within 100 to 150ms -useAtomsLogger({ +// Quick feedback +createLoggedStore(parentStore, { transactionDebounceMs: 50, requestIdleCallbackTimeoutMs: 100, - maxProcessingTimeMs: 10, // Short bursts to keep UI responsive + maxProcessingTimeMs: 10, }); -// Performance priority: group events aggressively, only log during idle time -useAtomsLogger({ +// Performance priority +createLoggedStore(parentStore, { transactionDebounceMs: 500, - requestIdleCallbackTimeoutMs: 0, // No maximum timeout, only log when truly idle - maxProcessingTimeMs: 50, // Longer processing time for better throughput + requestIdleCallbackTimeoutMs: 0, + maxProcessingTimeMs: 50, }); -// Balanced approach (default behavior) -useAtomsLogger({ +// Default +createLoggedStore(parentStore, { transactionDebounceMs: 250, requestIdleCallbackTimeoutMs: 250, maxProcessingTimeMs: 16, }); ``` -## Tree-shaking +
-Jotai Logger can be used in production mode. +
+Logging Performances -If you only want it in development mode we recommend wrapping the `AtomsLogger` in a conditional statement and tree-shake it out in production to avoid any accidental usage in production. +### Logging Performances -### Using with Vite.js +The logger logs all transactions asynchronously to avoid blocking the main thread. -For Vite.js applications, you can use environment variables to conditionally include the logger: +Internally, the logger uses a multi-stage approach: -```tsx -import { useAtomsLogger } from 'jotai-logger'; +1. **Debouncing**: Events are grouped into transactions using a debounce mechanism (`transactionDebounceMs`). +2. **Idle scheduling**: Transactions are scheduled using + [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) when the + browser is idle (`requestIdleCallbackTimeoutMs`). +3. **Batch processing**: Transactions are processed in batches to prevent blocking the main thread + (`maxProcessingTimeMs`). -function App() { - return ( - <> - {import.meta.env.DEV && } - {/* your app */} - - ); -} +This approach ensures that even when handling large queues of transactions, UI responsiveness is maintained by +spreading the work across multiple idle periods. -function AtomsLogger() { - useAtomsLogger(); - return null; -} +
+ +## Custom Formatters + +
+Custom Formatter + +### Formatter Option + +The `formatter` option accepts any function with the signature `(transaction: AtomTransaction) => void`, +letting you send atom events to any logging backend. + +```ts +import { createLoggedStore } from 'jotai-logger/vanilla'; +import type { AtomLoggerFormatter, AtomTransaction } from 'jotai-logger/vanilla'; + +const myFormatter: AtomLoggerFormatter = (transaction: AtomTransaction) => { + console.log('[jotai]', transaction.type, transaction.events); +}; + +const store = createLoggedStore(parentStore, { formatter: myFormatter }); ``` -### Using with Next.js +
-For Next.js applications, you can leverage environment variables or the built-in `process.env.NODE_ENV`: +
+Formatter example with logTape and ansis -```tsx -// App.tsx -import dynamic from 'next/dynamic'; +### Formatter example with logTape and ansis -const AtomsLogger = process.env.NODE_ENV === 'development' - ? dynamic(() => import('./AtomsLogger').then((mod) => ({ default: mod.AtomsLogger })), { ssr: false }) - : null; +Here's an example of a custom formatter that integrates with [logTape](https://github.com/dahlia/logtape) and uses [ansis](https://github.com/webdiscus/ansis) for color formatting in the console. -function App() { +```tsx +import { getLogger } from '@logtape/logtape'; +import ansis from 'ansis'; +import { + AtomEventTypes, + AtomLoggerProvider, + AtomTransactionTypes, + type AtomEvent, + type AtomLoggerFormatter, + type AtomTransaction, +} from 'jotai-logger'; +import React, { captureOwnerStack, type PropsWithChildren } from 'react'; + +// Create a logTape logger instance for jotai +const jotaiLogger = getLogger('jotai'); + +// Provider component to wrap your app and enable logging with logTape +export function LogTapeJotaiLoggerProvider({ children }: PropsWithChildren) { return ( - <> - {AtomsLogger && } - {/* your app */} - + + {children} + ); } -// AtomsLogger.tsx -import { useAtomsLogger } from 'jotai-logger'; +// Custom formatter that logs transactions and events to logTape with colors and structured properties +const logTapeJotaiFormatter: AtomLoggerFormatter = (transaction) => { + // Calculate elapsed time in milliseconds with 2 decimal places + const elapsed = ( + Math.round((transaction.endTimestamp - transaction.startTimestamp) * 100) / 100 + ).toFixed(2); + + // Parse the owner stack to get the top 2 components for context + const ownerStack = parseOwnerStack(transaction.ownerStack).splice(0, 2).join('.'); + + // Get the component display name if available + const componentName = transaction.componentDisplayName ?? ''; + + // Map transaction types to human-readable names with colors + const transactionName = { + [AtomTransactionTypes.unknown]: ansis.bold('unknown'), + [AtomTransactionTypes.storeGet]: ansis.bold.hex(Colors.blue)('store.get'), + [AtomTransactionTypes.storeSet]: ansis.bold.hex(Colors.yellow)('store.set'), + [AtomTransactionTypes.storeSubscribe]: ansis.bold.hex(Colors.green)('store.sub'), + [AtomTransactionTypes.storeUnsubscribe]: ansis.bold.hex(Colors.red)('store.unsubscribe'), + [AtomTransactionTypes.promiseResolved]: ansis.bold.hex(Colors.green)('promise.resolved'), + [AtomTransactionTypes.promiseRejected]: ansis.bold.hex(Colors.red)('promise.rejected'), + }[transaction.type]; + + // Prepare log properties without already logged fields + const logProperties: Record = { ...transaction }; + const keysToDelete: (keyof AtomTransaction)[] = [ + 'atom', + 'type', + 'transactionNumber', + 'ownerStack', + 'componentDisplayName', + 'events', + 'startTimestamp', + 'endTimestamp', + ]; + for (const key of keysToDelete) delete logProperties[key]; + + // Create the log message for the transaction + let log = ''; + log += `transaction ${transaction.transactionNumber} - `; + log += transactionName; + log += `(${transaction.atom?.toString() ?? ''})`; + log += ` - ${transaction.events.length} event${transaction.events.length > 1 ? 's' : ''}`; + log += ` - ${elapsed}ms`; + if (ownerStack) log += ` - [${ansis.reset(ownerStack)}]`; + if (componentName) log += ` ${ansis.reset(componentName)}`; + if (Object.keys(logProperties).length > 0) log += ` : {*}`; + log = ansis.hex(Colors.grey)(log); + + // Log the transaction with logTape, using a child logger for this transaction number and passing structured properties + const transactionLogger = jotaiLogger.getChild(`${transaction.transactionNumber}`); + transactionLogger.debug(log, logProperties); + + // Log each event in the transaction with its own child logger + for (const [eventIndex, event] of transaction.events.entries()) { + // Map event types to human-readable names with colors + const eventName = { + [AtomEventTypes.initialized]: ansis.bold.hex(Colors.blue)('initialized'), + [AtomEventTypes.initialPromisePending]: ansis.bold.hex(Colors.pink)('initialPromisePending'), + [AtomEventTypes.initialPromiseResolved]: ansis.bold.hex(Colors.green)( + 'initialPromiseResolved', + ), + [AtomEventTypes.initialPromiseRejected]: ansis.bold.hex(Colors.red)('initialPromiseRejected'), + [AtomEventTypes.initialPromiseAborted]: ansis.bold.hex(Colors.red)('initialPromiseAborted'), + [AtomEventTypes.changed]: ansis.bold.hex(Colors.lightBlue)('changed'), + [AtomEventTypes.changedPromisePending]: ansis.bold.hex(Colors.pink)('changedPromisePending'), + [AtomEventTypes.changedPromiseResolved]: ansis.bold.hex(Colors.green)( + 'changedPromiseResolved', + ), + [AtomEventTypes.changedPromiseRejected]: ansis.bold.hex(Colors.red)('changedPromiseRejected'), + [AtomEventTypes.changedPromiseAborted]: ansis.bold.hex(Colors.red)('changedPromiseAborted'), + [AtomEventTypes.dependenciesChanged]: ansis.bold.hex(Colors.yellow)('dependenciesChanged'), + [AtomEventTypes.mounted]: ansis.bold.hex(Colors.green)('mounted'), + [AtomEventTypes.unmounted]: ansis.bold.hex(Colors.red)('unmounted'), + [AtomEventTypes.destroyed]: ansis.bold.hex(Colors.red)('destroyed'), + }[event.type]; + + // Prepare log properties without already logged fields + const logProperties: Record = { ...event }; + const keysToDelete = ['type', 'atom'] satisfies (keyof AtomEvent)[]; + for (const key of keysToDelete) delete logProperties[key]; + for (const [key, value] of Object.entries(event)) { + // Convert Sets to arrays of strings for better logging + if (value instanceof Set) logProperties[key] = Array.from(value, (atom) => atom.toString()); + } + + // Create the log message for this event + let log = ''; + log += eventName; + log += ` ${ansis.reset(event.atom.toString())}`; + if (Object.keys(logProperties).length > 0) log += ` : {*}`; + log = ansis.hex(Colors.grey)(log); + + // Log the event with logTape, using a child logger for this event index and passing structured properties + const eventLogger = transactionLogger.getChild(`${eventIndex}`); + eventLogger.debug(log, logProperties); + } +}; -export function AtomsLogger() { - useAtomsLogger(); - return null; +/** + * Okabe-Ito colorblind-friendly palette. + * @see {@link https://siegal.bio.nyu.edu/color-palette/ | Okabe-Ito color palette} + * @see {@link https://github.com/Wendystraite/jotai-logger#colors | Jotai Logger Colors} + */ +const Colors = { + grey: '#757575', + yellow: '#E69F00', + lightBlue: '#56B4E9', + green: '#009E73', + blue: '#0072B2', + red: '#D55E00', + pink: '#CC79A7', +}; + +/** + * Parse a trace from {@link https://react.dev/reference/react/captureOwnerStack | captureOwnerStack} (React 19.1+) or any other source. + * @see {@link https://github.com/Wendystraite/jotai-logger#owner-stack-getownerstack | Jotai Logger Owner Stack Tracking} + * @see {@link https://github.com/Wendystraite/jotai-logger/blob/main/src/utils/parse-owner-stack.ts | Jotai Logger parseOwnerStack utility function} + */ +function parseOwnerStack(stack: string | null | undefined): string[] { + return (stack ?? '') + .split('\n') + .map((line) => /^\s*at\s+([^\s]+)\s+/.exec(line)?.[1]) + .filter((c) => typeof c === 'string'); +} + +/** + * Get the currently rendering React component's display name using React 19's internal APIs. + * @see {@link https://github.com/Wendystraite/jotai-logger#component-display-name-getcomponentdisplayname | Jotai Logger Component Display Name} + */ +function getReact19ComponentDisplayName(): string | undefined { + const React19 = React as { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?: { + A?: { getOwner?: () => { type?: { displayName?: string; name?: string } } }; + }; + __SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE?: { + A?: { getOwner?: () => { type?: { displayName?: string; name?: string } } }; + }; + }; + const component = ( + React19.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? + React19.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + )?.A?.getOwner?.().type; + return component?.displayName ?? component?.name; } ``` -## Example Logs +
-Here are some examples of what the logs look like in the console: +## Built-in Console Formatter -### Basic Transaction +The default formatter — `consoleFormatter()` from `jotai-logger/formatters/console` logs atom transactions to +the browser or Node.js console with colors, grouping, and timing information. -You can see a transaction as what triggered some atom changes and the following cascading events. +
+ConsoleFormatterOptions reference -When an atom is initialized or its change value, you'll see a transaction log like this: +### ConsoleFormatterOptions reference + +```ts +import { consoleFormatter } from 'jotai-logger/formatters/console'; +import type { ConsoleFormatterOptions } from 'jotai-logger/formatters/console'; + +type ConsoleFormatterOptions = { + /** Prefix shown before the transaction number in logs. */ + domain?: string; + + /** Custom logger object. @default console */ + logger?: Pick & Partial>; + + /** Group transactions with console.group. @default true */ + groupTransactions?: boolean; + + /** Group events inside a transaction with console.group. @default false */ + groupEvents?: boolean; + + /** Spaces per indentation level (0 = disabled). @default 0 */ + indentSpaces?: number; + + /** Use %c color/style formatting. @default true */ + formattedOutput?: boolean; + + /** Color palette: 'default' | 'light' | 'dark'. @default 'default' */ + colorScheme?: 'default' | 'light' | 'dark'; + + /** Max length of stringified values (0 = no limit). @default 50 */ + stringifyLimit?: number; + + /** Stringify atom values in logs. @default true */ + stringifyValues?: boolean; + + /** Custom value-to-string function. */ + stringify?: (value: unknown) => string; + + /** Show transaction number. @default true */ + showTransactionNumber?: boolean; + + /** Show event count per transaction. @default true */ + showTransactionEventsCount?: boolean; + + /** Show transaction start time (locale time string). @default false */ + showTransactionLocaleTime?: boolean; + + /** Show transaction elapsed time. @default true */ + showTransactionElapsedTime?: boolean; + + /** Pad fields for column alignment across transactions. @default true */ + autoAlignTransactions?: boolean; + + /** Collapse transaction groups by default. @default false */ + collapseTransactions?: boolean; + + /** Collapse event groups by default. @default false */ + collapseEvents?: boolean; + + /** Max parent components shown from owner stack. @default 2 */ + ownerStackLimit?: number; +}; +``` + +
+ +
+Colors + +### Colors + +The default color scheme uses colors easy to read in both light and dark mode, based on the colorblind-friendly +[Okabe-Ito palette](https://siegal.bio.nyu.edu/color-palette/). + +The `colorScheme` option adjusts contrast ratios to meet WCAG AA (min 5:1) on white (`#ffffff`) or dark +(`#282828`) backgrounds. + +```ts +import { consoleFormatter } from 'jotai-logger/formatters/console'; +import { createLoggedStore } from 'jotai-logger/vanilla'; + +// Follow the system preference +createLoggedStore(parentStore, { + formatter: consoleFormatter({ + colorScheme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', + }), +}); + +// Read from an environment variable (Vite) +createLoggedStore(parentStore, { + formatter: consoleFormatter({ + colorScheme: import.meta.env.VITE_ATOMS_LOGGER_COLOR_SCHEME, + }), +}); + +// Disable colors entirely +createLoggedStore(parentStore, { + formatter: consoleFormatter({ formattedOutput: false }), +}); +``` + +
+ +
+Stringification + +### Stringification + +By default atom values are converted to strings using `toString()` and `JSON.stringify`. + +- `stringifyValues`: enable/disable conversion (default: `true`) +- `stringifyLimit`: max output length in characters (default: `50`) +- `stringify`: custom serialiser function + +```ts +// Custom serialiser with @vitest/pretty-format +import { format as prettyFormat } from '@vitest/pretty-format'; +import { consoleFormatter } from 'jotai-logger/formatters/console'; +import { createLoggedStore } from 'jotai-logger/vanilla'; + +createLoggedStore(parentStore, { + formatter: consoleFormatter({ + stringifyValues: true, + stringifyLimit: 0, + stringify(value) { + return prettyFormat(value, { min: true, maxDepth: 3, maxWidth: 5 }); + }, + }), +}); +``` + +
+ +
+Example Logs + +### Example Logs + +
+Basic Transaction + +A transaction represents what triggered some atom changes and the cascading events that followed. ```ts const counterAtom = atom(0); @@ -425,14 +704,14 @@ store.set(counterAtom, 1); ``` ▶ transaction 1 - 2.35ms - 1 event : retrieved value of atom1:counter ▼ initialized value of atom1:counter to 0 - value: 1 + value: 0 ▶ transaction 2 - 4.00ms - 1 event : set value of atom1:counter to 1 ▼ changed value of atom1:counter from 0 to 1 old value: 0 new value: 1 ``` -If a changed atom has dependents atoms, their new values will be in the same transaction: +If a changed atom has dependent atoms, their new values appear in the same transaction: ```ts const resultAtom = atom((get) => get(counterAtom) * 2); @@ -445,9 +724,10 @@ resultAtom.debugLabel = 'result'; ▶ changed value of atom2:result from 2 to 4 ``` -### Atom setter calls +
-If you call a write only atom method, it will trigger a new transaction : +
+Atom setter calls ```ts const incrementCounterAtom = atom(null, (get, set) => { @@ -462,13 +742,14 @@ store.set(incrementCounterAtom); ▶ changed value of atom1:counter from 3 to 4 ``` -### Async Transaction +
-When working with asynchronous atoms, multiple transactions will be triggered based on the promise state : +
+Async Transaction ```ts const userDataAsyncAtom = atomWithQuery(...); -userDataAsyncAtom.debugLabel = "userDataAsync"; +userDataAsyncAtom.debugLabel = 'userDataAsync'; ``` ``` @@ -479,22 +760,20 @@ userDataAsyncAtom.debugLabel = "userDataAsync"; ▶ resolved initial promise of atom4:userDataAsync to {"name":"Daishi"} ``` -Just like promises, these transactions can be either pending, resolved, rejected or aborted. +Transactions can be pending, resolved, rejected, or aborted. -### Mount and Unmount +
-When an atom is mounted or unmounted, you'll see logs like this: +
+Mount and Unmount ```ts -// Vanilla style : counter is mounted when calling store.sub -const unsub = store.sub(counterAtom, () => { - console.log('counterAtom value is changed to', store.get(counterAtom)); -}); +// Vanilla +const unsub = store.sub(counterAtom, () => {}); -// React style : counter is mounted when calling useAtomValue +// React function MyCounter() { const count = useAtomValue(counterAtom); - // .. } ``` @@ -506,9 +785,10 @@ function MyCounter() { ▶ unmounted atom4 ``` -### Dependency Tracking +
-When an atom is used in a derived atom, the logger will show their dependencies and dependents: +
+Dependency Tracking ```ts const derivedAtom = atom((get) => `${get(counterAtom)} is the count`); @@ -523,17 +803,7 @@ derivedAtom.debugLabel = 'derived'; ▶ mounted atom5:derived ``` -If the derived atom has its dependencies changed, the logger will notify you: - -```ts -const atomWithVariableDeps = atom((get) => { - if (get(isEnabledAtom)) { - const aValue = get(anAtom); - } else { - const anotherValue = get(anotherAtom); - } -}); -``` +If an atom's dependencies change: ``` ▶ transaction 10 - 2 events : @@ -543,68 +813,214 @@ const atomWithVariableDeps = atom((get) => { new dependencies: ["atom6:isEnabledAtom", "atom9:anotherAtom"] ``` -### React components +
-If the `getOwnerStack` option is used the logger will log the parent React component that triggered the transaction. +
+React component tracking + +With `getOwnerStack` — shows parent component hierarchy: ``` ▶ transaction 11 : [MyApp.MyParent] retrieved value of atom10 ▶ initialized value of atom10 to false ``` -If the `getComponentDisplayName` option is used the logger will log the current React component that triggered the transaction. - -Note that, if using `getReact19ComponentDisplayName`, the component display name will only be shown when initializing atoms. -It will not be shown for other events like retrieving or setting atom values. +With `getComponentDisplayName` — shows the currently rendering component: ``` ▶ transaction 11 : MyComponent retrieved value of atom10 ▶ initialized value of atom10 to false ``` -When both `getOwnerStack` and `getComponentDisplayName` are used, the logger will show both the parent components and the current component. +With both combined: ``` ▶ transaction 11 : [MyApp.MyParent] MyComponent retrieved value of atom10 ▶ initialized value of atom10 to false ``` -## Logging performances +
-The logger logs all transactions asynchronously to avoid blocking the main thread and ensure optimal performance. +
-Internally, the logger uses a multi-stage approach: +## Tree-shaking + +Jotai Logger can be used in production mode. If you only want it in development, wrap the component in a +conditional and tree-shake it out to avoid accidental production usage. + +
+Using with Vite.js + +### Using with Vite.js + +```tsx +import { AtomLoggerProvider } from 'jotai-logger'; + +function App() { + return ( + <> + {import.meta.env.DEV ? ( + + + + ) : ( + + )} + + ); +} +``` + +
-1. **Debouncing**: Events are grouped into transactions using a debounce mechanism (with a default debounce period of 250ms / see `transactionDebounceMs` option). -2. **Idle scheduling**: Transactions are scheduled to be logged using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) when the browser is idle (with a default timeout of 250ms / see `requestIdleCallbackTimeoutMs` option). -3. **Batch processing**: Transactions are processed in batches with a maximum processing time limit to prevent blocking the main thread (with 16ms per batch by default / see `maxProcessingTimeMs` option). +
+Using with Next.js -This approach ensures that even when handling large queues of transactions, UI responsiveness is maintained by spreading the work across multiple idle periods. +### Using with Next.js + +```tsx +// App.tsx +import dynamic from 'next/dynamic'; + +const AtomLoggerProvider = + process.env.NODE_ENV === 'development' + ? dynamic(() => import('jotai-logger').then((mod) => ({ default: mod.AtomLoggerProvider })), { + ssr: false, + }) + : null; + +function App() { + return ( + <> + {AtomLoggerProvider ? ( + + + + ) : ( + + )} + + ); +} +``` + +
## Lifecycle of atoms -Here's a brief overview of the lifecycle of atoms in Jotai and how they relate to the logger: +
+Atom lifecycle events + +- **initialized** — the atom is created and its value is set for the first time. +- **changed** — the atom value changed. +- **mounted** — something subscribed to its value or one of its dependents. +- **unmounted** — all subscribers are gone. +- **destroyed** — the atom is no longer referenced and its value is removed from memory. +- **pending / resolved / rejected / aborted** — states for async atoms. + +
+ +
+How the lifecycle events are triggered in vanilla Jotai + +- `store.get`, `store.set`, `store.sub` → atom is **initialized**. +- `store.sub` → atom is **mounted**; the returned unsubscribe function → **unmounted**. +- `store.set` → atom is **changed**. -- When an atom is **initialized** this means that the atom is created and its value is set for the first time. -- When an atom is **changed** this means that the atom value changed. -- When an atom is **mounted** this means that something is subscribed to its value or one of its dependents. -- When an atom is **unmounted** this means that all subscribers are gone. -- When an atom is **destroyed** this means that the atom is no longer used and its value is removed from memory. -- When an async atom is used, its state will either be **pending**, **resolved**, **rejected** or **aborted**. +
-In Jotai : +
+How the lifecycle events are triggered in React -- When using `store.get`, `store.set` or `store.sub`, the atom is **initialized**. -- When using `store.sub`, the atom is **mounted** when `store.sub` is called and **unmounted** when the `unsubscribe` method is called. -- When using `store.set`, the atom is **changed**. +- `useAtom` / `useAtomValue` → atom is **initialized** then **mounted**. +- All components stop using the atom → **unmounted**. +- `useAtom` / `useSetAtom` setter → atom is **changed**. -In React : +
-- When using `useAtom` or `useAtomValue`, the atom is **initialized** and then **mounted** (it uses `store.get` and `store.sub`). -- When all components are not using `useAtom` and `useAtomValue` on an atom, it is **unmounted**. -- When calling `useAtom` or `useSetAtom`'s setter function, the atom is **changed** (it uses `store.set`). +
+Memory management -Memory management : +Jotai uses a `WeakMap` to store atom state, so when an atom is no longer referenced it is removed by the garbage +collector. The logger uses +[FinalizationRegistry](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry) +to track when atoms are destroyed. -Jotai uses a `WeakMap` to store the atom state, so when the atom is no longer referenced, it will be removed from memory by the garbage collector. -The logger uses [FinalizationRegistry](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry) to track when the atom is destroyed. +
+ +## Migration guide + +
+From v4 to v5 + +The v5 API no longer mutates the store. Instead of patching `store.get/set/sub` in place, it +creates a **new derived store** that shares all internal state with the parent. + +**React API** : + +`useAtomsLogger` is replaced by `AtomLoggerProvider`, a Provider-like component that +automatically picks up the nearest Jotai store from context and wraps children in a new logged store: + +```diff +- import { useAtomsLogger } from 'jotai-logger'; ++ import { AtomLoggerProvider } from 'jotai-logger'; + +- function AtomsLoggerComponent() { +- useAtomsLogger(options); +- return null; +- } +- + function App() { + return ( + +- +- ++ ++ ++ + + ); + } +``` + +All props of `AtomLoggerProvider` are the same options as `AtomLoggerOptions`. + +**Vanilla API** : + +`bindAtomsLoggerToStore` is replaced by `createLoggedStore` that creates and return a new store: + +```diff +- import { bindAtomsLoggerToStore } from 'jotai-logger'; ++ import { createLoggedStore } from 'jotai-logger'; + + const parentStore = createStore(); +- bindAtomsLoggerToStore(parentStore, options); +- parentStore.get(myAtom); ++ const store = createLoggedStore(parentStore, options); ++ store.get(myAtom); +``` + +`isAtomsLoggerBoundToStore` → `isLoggedStore`: + +```diff +- import { isAtomsLoggerBoundToStore } from 'jotai-logger/vanilla'; ++ import { isLoggedStore } from 'jotai-logger/vanilla'; + +- isAtomsLoggerBoundToStore(store); ++ isLoggedStore(store); +``` + +Updating options at runtime (no re-bind; mutate the logger options directly): + +```diff + const options: AtomLoggerOptions = { enabled: true }; + +- bindAtomsLoggerToStore(parentStore, options); ++ const store = createLoggedStore(parentStore, options); + + // Change options at runtime +- bindAtomsLoggerToStore(store, { enabled: false }); ++ options.enabled = false; ++ // or ++ getLoggedStoreOptions(store)!.enabled = false; +``` diff --git a/benchmarks/atom-creation.json b/benchmarks/atom-creation.json new file mode 100644 index 0000000..732da49 --- /dev/null +++ b/benchmarks/atom-creation.json @@ -0,0 +1,45 @@ +{ + "name": "atom-creation", + "date": "2026-05-08T23:02:55.585Z", + "version": null, + "results": [ + { + "name": "create 10k primitive atoms", + "ops": 5220, + "margin": 3.38, + "percentSlower": 4.81 + }, + { + "name": "create 10k derived atoms", + "ops": 5484, + "margin": 0.58, + "percentSlower": 0 + }, + { + "name": "set 10k atoms in store", + "ops": 75, + "margin": 0.91, + "percentSlower": 98.63 + }, + { + "name": "set 10k atoms in store [with logger]", + "ops": 23, + "margin": 2.28, + "percentSlower": 99.58 + }, + { + "name": "set 10k atoms in store [with console formatter]", + "ops": 14, + "margin": 1.91, + "percentSlower": 99.74 + } + ], + "fastest": { + "name": "create 10k derived atoms", + "index": 1 + }, + "slowest": { + "name": "set 10k atoms in store [with console formatter]", + "index": 4 + } +} \ No newline at end of file diff --git a/benchmarks/atom-creation.ts b/benchmarks/atom-creation.ts new file mode 100644 index 0000000..e8a1bbe --- /dev/null +++ b/benchmarks/atom-creation.ts @@ -0,0 +1,82 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [atom-creation.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/atom-creation.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + await suite( + 'atom-creation', + add('create 10k primitive atoms', () => { + return () => { + for (let i = 0; i < 10_000; i++) { + atom(i); + } + }; + }), + add('create 10k derived atoms', () => { + const base = atom(0); + return () => { + for (let i = 0; i < 10_000; i++) { + atom((get) => get(base) + i); + } + }; + }), + add('set 10k atoms in store', () => { + return () => { + const store = createStore(); + for (let i = 0; i < 10_000; i++) { + store.set(atom(i), i); + } + }; + }), + add('set 10k atoms in store [with logger]', () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + for (let i = 0; i < 10_000; i++) { + store.set(atom(i), i); + } + }; + }), + add('set 10k atoms in store [with console formatter]', () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + for (let i = 0; i < 10_000; i++) { + store.set(atom(i), i); + } + }; + }), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'atom-creation', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'atom-creation', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/computed-read.json b/benchmarks/computed-read.json new file mode 100644 index 0000000..24f6797 --- /dev/null +++ b/benchmarks/computed-read.json @@ -0,0 +1,87 @@ +{ + "name": "computed-read", + "date": "2026-05-08T23:04:02.111Z", + "version": null, + "results": [ + { + "name": "read chain depth=1", + "ops": 127442, + "margin": 2.49, + "percentSlower": 0 + }, + { + "name": "read chain depth=1 [with logger]", + "ops": 41509, + "margin": 1.64, + "percentSlower": 67.43 + }, + { + "name": "read chain depth=1 [with console formatter]", + "ops": 30547, + "margin": 1.29, + "percentSlower": 76.03 + }, + { + "name": "read chain depth=5", + "ops": 49502, + "margin": 1.4, + "percentSlower": 61.16 + }, + { + "name": "read chain depth=5 [with logger]", + "ops": 21893, + "margin": 2.64, + "percentSlower": 82.82 + }, + { + "name": "read chain depth=5 [with console formatter]", + "ops": 16903, + "margin": 0.95, + "percentSlower": 86.74 + }, + { + "name": "read chain depth=10", + "ops": 33804, + "margin": 1.33, + "percentSlower": 73.47 + }, + { + "name": "read chain depth=10 [with logger]", + "ops": 15588, + "margin": 1.41, + "percentSlower": 87.77 + }, + { + "name": "read chain depth=10 [with console formatter]", + "ops": 9694, + "margin": 2.67, + "percentSlower": 92.39 + }, + { + "name": "read chain depth=50", + "ops": 8263, + "margin": 1.13, + "percentSlower": 93.52 + }, + { + "name": "read chain depth=50 [with logger]", + "ops": 3969, + "margin": 1.16, + "percentSlower": 96.89 + }, + { + "name": "read chain depth=50 [with console formatter]", + "ops": 2558, + "margin": 1.29, + "percentSlower": 97.99 + } + ], + "fastest": { + "name": "read chain depth=1", + "index": 0 + }, + "slowest": { + "name": "read chain depth=50 [with console formatter]", + "index": 11 + } +} \ No newline at end of file diff --git a/benchmarks/computed-read.ts b/benchmarks/computed-read.ts new file mode 100644 index 0000000..b56214e --- /dev/null +++ b/benchmarks/computed-read.ts @@ -0,0 +1,94 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import type { Atom } from 'jotai'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [computed-read.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/computed-read.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +function buildDerivedChain(depth: number): Atom { + const base = atom(0); + let prev: Atom = base; + for (let i = 0; i < depth; i++) { + const p = prev; + prev = atom((get) => get(p) + 1); + } + return prev; +} + +const main = async () => { + await suite( + 'computed-read', + ...[1, 5, 10, 50] + .map((depth) => [ + add(`read chain depth=${depth}`, () => { + const store = createStore(); + const leaf = buildDerivedChain(depth); + const unsub = store.sub(leaf, () => {}); + store.get(leaf); // prime + return { + fn: () => store.get(leaf), + teardown: () => { + unsub(); + }, + }; + }), + add(`read chain depth=${depth} [with logger]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const leaf = buildDerivedChain(depth); + const unsub = store.sub(leaf, () => {}); + store.get(leaf); // prime + return { + fn: () => store.get(leaf), + teardown: () => { + unsub(); + }, + }; + }), + add(`read chain depth=${depth} [with console formatter]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const leaf = buildDerivedChain(depth); + const unsub = store.sub(leaf, () => {}); + store.get(leaf); // prime + return { + fn: () => store.get(leaf), + teardown: () => { + unsub(); + }, + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'computed-read', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'computed-read', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/derived-chain.json b/benchmarks/derived-chain.json new file mode 100644 index 0000000..7e79968 --- /dev/null +++ b/benchmarks/derived-chain.json @@ -0,0 +1,87 @@ +{ + "name": "derived-chain", + "date": "2026-05-08T23:05:08.200Z", + "version": null, + "results": [ + { + "name": "depth=10", + "ops": 21394, + "margin": 1.19, + "percentSlower": 0 + }, + { + "name": "depth=10 [with logger]", + "ops": 10590, + "margin": 0.9, + "percentSlower": 50.5 + }, + { + "name": "depth=10 [with console formatter]", + "ops": 6062, + "margin": 2.37, + "percentSlower": 71.66 + }, + { + "name": "depth=50", + "ops": 4669, + "margin": 1.14, + "percentSlower": 78.18 + }, + { + "name": "depth=50 [with logger]", + "ops": 2325, + "margin": 1.09, + "percentSlower": 89.13 + }, + { + "name": "depth=50 [with console formatter]", + "ops": 1426, + "margin": 1.16, + "percentSlower": 93.33 + }, + { + "name": "depth=100", + "ops": 2428, + "margin": 1.16, + "percentSlower": 88.65 + }, + { + "name": "depth=100 [with logger]", + "ops": 1149, + "margin": 1.02, + "percentSlower": 94.63 + }, + { + "name": "depth=100 [with console formatter]", + "ops": 712, + "margin": 1.24, + "percentSlower": 96.67 + }, + { + "name": "depth=200", + "ops": 1186, + "margin": 1.26, + "percentSlower": 94.46 + }, + { + "name": "depth=200 [with logger]", + "ops": 502, + "margin": 1.31, + "percentSlower": 97.65 + }, + { + "name": "depth=200 [with console formatter]", + "ops": 330, + "margin": 1.25, + "percentSlower": 98.46 + } + ], + "fastest": { + "name": "depth=10", + "index": 0 + }, + "slowest": { + "name": "depth=200 [with console formatter]", + "index": 11 + } +} \ No newline at end of file diff --git a/benchmarks/derived-chain.ts b/benchmarks/derived-chain.ts new file mode 100644 index 0000000..102e37f --- /dev/null +++ b/benchmarks/derived-chain.ts @@ -0,0 +1,93 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import type { Atom } from 'jotai'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [derived-chain.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/derived-chain.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + await suite( + 'derived-chain', + ...[10, 50, 100, 200] + .map((depth) => [ + add(`depth=${depth}`, () => { + return () => { + const store = createStore(); + const base = atom(0); + let prev: Atom = base; + for (let i = 0; i < depth; i++) { + const p = prev; + prev = atom((get) => get(p) + 1); + } + const leaf = prev; + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + add(`depth=${depth} [with logger]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const base = atom(0); + let prev: Atom = base; + for (let i = 0; i < depth; i++) { + const p = prev; + prev = atom((get) => get(p) + 1); + } + const leaf = prev; + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + add(`depth=${depth} [with console formatter]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const base = atom(0); + let prev: Atom = base; + for (let i = 0; i < depth; i++) { + const p = prev; + prev = atom((get) => get(p) + 1); + } + const leaf = prev; + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'derived-chain', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'derived-chain', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/diamond.json b/benchmarks/diamond.json new file mode 100644 index 0000000..81cd36f --- /dev/null +++ b/benchmarks/diamond.json @@ -0,0 +1,87 @@ +{ + "name": "diamond", + "date": "2026-05-08T23:06:19.919Z", + "version": null, + "results": [ + { + "name": "base→10 mid→leaf", + "ops": 16281, + "margin": 2.12, + "percentSlower": 0 + }, + { + "name": "base→10 mid→leaf [with logger]", + "ops": 7206, + "margin": 1.08, + "percentSlower": 55.74 + }, + { + "name": "base→10 mid→leaf [with console formatter]", + "ops": 4630, + "margin": 1.33, + "percentSlower": 71.56 + }, + { + "name": "base→50 mid→leaf", + "ops": 3729, + "margin": 0.9, + "percentSlower": 77.1 + }, + { + "name": "base→50 mid→leaf [with logger]", + "ops": 1289, + "margin": 1.42, + "percentSlower": 92.08 + }, + { + "name": "base→50 mid→leaf [with console formatter]", + "ops": 948, + "margin": 1.11, + "percentSlower": 94.18 + }, + { + "name": "base→100 mid→leaf", + "ops": 1312, + "margin": 5.47, + "percentSlower": 91.94 + }, + { + "name": "base→100 mid→leaf [with logger]", + "ops": 305, + "margin": 3.33, + "percentSlower": 98.13 + }, + { + "name": "base→100 mid→leaf [with console formatter]", + "ops": 229, + "margin": 3.52, + "percentSlower": 98.59 + }, + { + "name": "base→200 mid→leaf", + "ops": 607, + "margin": 3.96, + "percentSlower": 96.27 + }, + { + "name": "base→200 mid→leaf [with logger]", + "ops": 111, + "margin": 3.22, + "percentSlower": 99.32 + }, + { + "name": "base→200 mid→leaf [with console formatter]", + "ops": 89, + "margin": 4.32, + "percentSlower": 99.45 + } + ], + "fastest": { + "name": "base→10 mid→leaf", + "index": 0 + }, + "slowest": { + "name": "base→200 mid→leaf [with console formatter]", + "index": 11 + } +} \ No newline at end of file diff --git a/benchmarks/diamond.ts b/benchmarks/diamond.ts new file mode 100644 index 0000000..4d31693 --- /dev/null +++ b/benchmarks/diamond.ts @@ -0,0 +1,102 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import type { Atom } from 'jotai'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [diamond.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/diamond.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + await suite( + 'diamond', + ...[10, 50, 100, 200] + .map((midCount) => [ + add(`base→${midCount} mid→leaf`, () => { + return () => { + const store = createStore(); + const base = atom(0); + const mid: Atom[] = []; + for (let i = 0; i < midCount; i++) { + mid.push(atom((get) => get(base) + i)); + } + const leaf = atom((get) => { + let sum = 0; + for (const m of mid) sum += get(m); + return sum; + }); + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + add(`base→${midCount} mid→leaf [with logger]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const base = atom(0); + const mid: Atom[] = []; + for (let i = 0; i < midCount; i++) { + mid.push(atom((get) => get(base) + i)); + } + const leaf = atom((get) => { + let sum = 0; + for (const m of mid) sum += get(m); + return sum; + }); + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + add(`base→${midCount} mid→leaf [with console formatter]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const base = atom(0); + const mid: Atom[] = []; + for (let i = 0; i < midCount; i++) { + mid.push(atom((get) => get(base) + i)); + } + const leaf = atom((get) => { + let sum = 0; + for (const m of mid) sum += get(m); + return sum; + }); + const unsub = store.sub(leaf, () => {}); + store.set(base, 1); + unsub(); + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'diamond', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'diamond', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/inspect-me.ts b/benchmarks/inspect-me.ts index f338f1c..4efd288 100644 --- a/benchmarks/inspect-me.ts +++ b/benchmarks/inspect-me.ts @@ -1,16 +1,16 @@ import { atom, createStore } from 'jotai'; -import { bindAtomsLoggerToStore } from '../dist/bind-atoms-logger-to-store.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; const ITERATIONS = 10_000; console.log(`running ${ITERATIONS} iterations...`); -const store = createStore(); +let store = createStore(); -bindAtomsLoggerToStore(store, { +store = createLoggedStore(store, { synchronous: true, - logger: { log: () => {}, group: () => {}, groupEnd: () => {} }, + formatter: () => {}, }); const unsubscribes: (() => void)[] = []; diff --git a/benchmarks/read-write-batch.json b/benchmarks/read-write-batch.json new file mode 100644 index 0000000..670d8d1 --- /dev/null +++ b/benchmarks/read-write-batch.json @@ -0,0 +1,51 @@ +{ + "name": "read-write-batch", + "date": "2026-05-08T23:17:59.511Z", + "version": null, + "results": [ + { + "name": "write 10k", + "ops": 267589, + "margin": 1.68, + "percentSlower": 0 + }, + { + "name": "write 10k [with logger]", + "ops": 76657, + "margin": 1.33, + "percentSlower": 71.35 + }, + { + "name": "write 10k [with console formatter]", + "ops": 63085, + "margin": 1.62, + "percentSlower": 76.42 + }, + { + "name": "read 10k", + "ops": 258311, + "margin": 0.94, + "percentSlower": 3.47 + }, + { + "name": "read 10k [with logger]", + "ops": 77806, + "margin": 1.15, + "percentSlower": 70.92 + }, + { + "name": "read 10k [with console formatter]", + "ops": 60416, + "margin": 1.35, + "percentSlower": 77.42 + } + ], + "fastest": { + "name": "write 10k", + "index": 0 + }, + "slowest": { + "name": "read 10k [with console formatter]", + "index": 5 + } +} \ No newline at end of file diff --git a/benchmarks/read-write.ts b/benchmarks/read-write.ts new file mode 100644 index 0000000..3255ae1 --- /dev/null +++ b/benchmarks/read-write.ts @@ -0,0 +1,247 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [read-write.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/read-write.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + // Suite 1: Batch read/write on a single mounted atom + await suite( + 'read-write-batch', + add('write 10k', () => { + const store = createStore(); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(a, i); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('write 10k [with logger]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(a, i); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('write 10k [with console formatter]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(a, i); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('read 10k', () => { + const store = createStore(); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.get(a); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('read 10k [with logger]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.get(a); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('read 10k [with console formatter]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const a = atom(0); + const unsub = store.sub(a, () => {}); + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.get(a); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'read-write-batch', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'read-write-batch', + format: 'chart.html', + }), + ); + + // Suite 2: Single read/write scaling by store size + await suite( + 'store-size-scaling', + ...[2, 3, 4, 5].map((n) => + add(`read atoms=${10 ** n}`, () => { + const store = createStore(); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => store.get(target); + }), + ), + ...[2, 3, 4, 5].map((n) => + add(`read atoms=${10 ** n} [with logger]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => store.get(target); + }), + ), + ...[2, 3, 4, 5].map((n) => + add(`read atoms=${10 ** n} [with console formatter]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => store.get(target); + }), + ), + ...[2, 3, 4, 5].map((n) => + add(`write atoms=${10 ** n}`, () => { + const store = createStore(); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => { + store.set(target, (c) => c + 1); + }; + }), + ), + ...[2, 3, 4, 5].map((n) => + add(`write atoms=${10 ** n} [with logger]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => { + store.set(target, (c) => c + 1); + }; + }), + ), + ...[2, 3, 4, 5].map((n) => + add(`write atoms=${10 ** n} [with console formatter]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const target = atom(0); + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.set(a, i); + } + store.set(target, 0); + return () => { + store.set(target, (c) => c + 1); + }; + }), + ), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'store-size-scaling', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'store-size-scaling', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/select-atom.json b/benchmarks/select-atom.json new file mode 100644 index 0000000..b659efe --- /dev/null +++ b/benchmarks/select-atom.json @@ -0,0 +1,51 @@ +{ + "name": "select-atom", + "date": "2026-05-08T23:20:52.534Z", + "version": null, + "results": [ + { + "name": "selectAtom 10k writes (relevant key)", + "ops": 138312, + "margin": 1.45, + "percentSlower": 0 + }, + { + "name": "selectAtom 10k writes (relevant key) [with logger]", + "ops": 43918, + "margin": 2.1, + "percentSlower": 68.25 + }, + { + "name": "selectAtom 10k writes (relevant key) [with console formatter]", + "ops": 30291, + "margin": 2.42, + "percentSlower": 78.1 + }, + { + "name": "selectAtom 10k writes (irrelevant key)", + "ops": 120818, + "margin": 1.4, + "percentSlower": 12.65 + }, + { + "name": "selectAtom 10k writes (irrelevant key) [with logger]", + "ops": 45740, + "margin": 1.23, + "percentSlower": 66.93 + }, + { + "name": "selectAtom 10k writes (irrelevant key) [with console formatter]", + "ops": 32131, + "margin": 1.36, + "percentSlower": 76.77 + } + ], + "fastest": { + "name": "selectAtom 10k writes (relevant key)", + "index": 0 + }, + "slowest": { + "name": "selectAtom 10k writes (relevant key) [with console formatter]", + "index": 2 + } +} \ No newline at end of file diff --git a/benchmarks/select-atom.ts b/benchmarks/select-atom.ts new file mode 100644 index 0000000..488fe31 --- /dev/null +++ b/benchmarks/select-atom.ts @@ -0,0 +1,158 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import { atom, createStore } from 'jotai'; +import { selectAtom } from 'jotai/vanilla/utils'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [select-atom.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/select-atom.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + await suite( + 'select-atom', + add('selectAtom 10k writes (relevant key)', () => { + const store = createStore(); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: i, name: 'test' }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('selectAtom 10k writes (relevant key) [with logger]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: i, name: 'test' }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('selectAtom 10k writes (relevant key) [with console formatter]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: i, name: 'test' }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('selectAtom 10k writes (irrelevant key)', () => { + const store = createStore(); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: 0, name: `test-${i}` }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('selectAtom 10k writes (irrelevant key) [with logger]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: 0, name: `test-${i}` }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + add('selectAtom 10k writes (irrelevant key) [with console formatter]', () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const base = atom({ count: 0, name: 'test' }); + const countAtom = selectAtom(base, (v) => v.count); + const unsub = store.sub(countAtom, () => {}); + store.get(countAtom); // prime + return { + fn: () => { + for (let i = 0; i < 10_000; i++) { + store.set(base, { count: 0, name: `test-${i}` }); + store.get(countAtom); + } + }, + teardown: () => { + unsub(); + }, + }; + }), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'select-atom', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'select-atom', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/simple-read.json b/benchmarks/simple-read.json deleted file mode 100644 index 4431190..0000000 --- a/benchmarks/simple-read.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "simple-read", - "date": "2025-06-02T12:11:02.638Z", - "version": null, - "results": [ - { - "name": "atoms=100", - "ops": 7840875, - "margin": 0.84, - "percentSlower": 0 - }, - { - "name": "atoms=100 [with logger]", - "ops": 2447977, - "margin": 1.61, - "percentSlower": 68.78 - }, - { - "name": "atoms=1000", - "ops": 5538725, - "margin": 2, - "percentSlower": 29.36 - }, - { - "name": "atoms=1000 [with logger]", - "ops": 2340945, - "margin": 0.84, - "percentSlower": 70.14 - }, - { - "name": "atoms=10000", - "ops": 5707456, - "margin": 1.01, - "percentSlower": 27.21 - }, - { - "name": "atoms=10000 [with logger]", - "ops": 2331233, - "margin": 0.82, - "percentSlower": 70.27 - }, - { - "name": "atoms=100000", - "ops": 5803092, - "margin": 1.07, - "percentSlower": 25.99 - }, - { - "name": "atoms=100000 [with logger]", - "ops": 2309691, - "margin": 0.87, - "percentSlower": 70.54 - } - ], - "fastest": { - "name": "atoms=100", - "index": 0 - }, - "slowest": { - "name": "atoms=100000 [with logger]", - "index": 7 - } -} \ No newline at end of file diff --git a/benchmarks/simple-read.ts b/benchmarks/simple-read.ts deleted file mode 100644 index 33d3d43..0000000 --- a/benchmarks/simple-read.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { add, complete, cycle, save, suite } from 'benny'; -import { type PrimitiveAtom, atom, createStore } from 'jotai'; - -import { bindAtomsLoggerToStore } from '../src/bind-atoms-logger-to-store.js'; - -/** - * Taken from Jotai benchmarks : [simple-read.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/simple-read.ts). - */ - -const createStateWithAtoms = (n: number, { withLogger }: { withLogger: boolean }) => { - let targetAtom: PrimitiveAtom | undefined; - const store = createStore(); - if (withLogger) { - bindAtomsLoggerToStore(store, { - synchronous: true, - logger: { log: () => {}, group: () => {}, groupEnd: () => {} }, - }); - } - for (let i = 0; i < n; ++i) { - const a = atom(i); - targetAtom ??= a; - store.set(a, i); - } - if (!targetAtom) { - throw new Error(); - } - return [store, targetAtom] as const; -}; - -const main = async () => { - await suite( - 'simple-read', - ...[2, 3, 4, 5] - .map((n) => [ - add(`atoms=${10 ** n}`, () => { - const [store, targetAtom] = createStateWithAtoms(10 ** n, { withLogger: false }); - return () => store.get(targetAtom); - }), - add(`atoms=${10 ** n} [with logger]`, () => { - const [store, targetAtom] = createStateWithAtoms(10 ** n, { withLogger: true }); - return () => store.get(targetAtom); - }), - ]) - .flat(1), - cycle(), - complete(), - save({ - folder: import.meta.dirname, - file: 'simple-read', - format: 'json', - }), - save({ - folder: import.meta.dirname, - file: 'simple-read', - format: 'chart.html', - }), - ); -}; - -void main(); diff --git a/benchmarks/simple-write.json b/benchmarks/simple-write.json deleted file mode 100644 index d7f0590..0000000 --- a/benchmarks/simple-write.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "name": "simple-write", - "date": "2025-06-02T12:11:52.181Z", - "version": null, - "results": [ - { - "name": "atoms=100", - "ops": 1017907, - "margin": 2.72, - "percentSlower": 3.29 - }, - { - "name": "atoms=100 [with logger]", - "ops": 95127, - "margin": 1.24, - "percentSlower": 90.96 - }, - { - "name": "atoms=1000", - "ops": 963012, - "margin": 4.32, - "percentSlower": 8.51 - }, - { - "name": "atoms=1000 [with logger]", - "ops": 94162, - "margin": 1.25, - "percentSlower": 91.05 - }, - { - "name": "atoms=10000", - "ops": 1006271, - "margin": 3.35, - "percentSlower": 4.4 - }, - { - "name": "atoms=10000 [with logger]", - "ops": 95347, - "margin": 1.24, - "percentSlower": 90.94 - }, - { - "name": "atoms=100000", - "ops": 1052558, - "margin": 3.08, - "percentSlower": 0 - }, - { - "name": "atoms=100000 [with logger]", - "ops": 95509, - "margin": 1.11, - "percentSlower": 90.93 - } - ], - "fastest": { - "name": "atoms=100000", - "index": 6 - }, - "slowest": { - "name": "atoms=1000 [with logger]", - "index": 3 - } -} \ No newline at end of file diff --git a/benchmarks/simple-write.ts b/benchmarks/simple-write.ts deleted file mode 100644 index 960504c..0000000 --- a/benchmarks/simple-write.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { add, complete, cycle, save, suite } from 'benny'; -import { type PrimitiveAtom, atom, createStore } from 'jotai'; - -import { bindAtomsLoggerToStore } from '../src/bind-atoms-logger-to-store.js'; - -/** - * Taken from Jotai benchmarks : [simple-write.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/simple-write.ts). - */ - -const createStateWithAtoms = (n: number, { withLogger }: { withLogger: boolean }) => { - let targetAtom: PrimitiveAtom | undefined; - const store = createStore(); - if (withLogger) { - bindAtomsLoggerToStore(store, { - synchronous: true, - logger: { log: () => {}, group: () => {}, groupEnd: () => {} }, - }); - } - for (let i = 0; i < n; ++i) { - const a = atom(i); - targetAtom ??= a; - store.set(a, i); - } - if (!targetAtom) { - throw new Error(); - } - return [store, targetAtom] as const; -}; - -const main = async () => { - await suite( - 'simple-write', - ...[2, 3, 4, 5] - .map((n) => [ - add(`atoms=${10 ** n}`, () => { - const [store, targetAtom] = createStateWithAtoms(10 ** n, { withLogger: false }); - return () => { - store.set(targetAtom, (c) => c + 1); - }; - }), - add(`atoms=${10 ** n} [with logger]`, () => { - const [store, targetAtom] = createStateWithAtoms(10 ** n, { withLogger: true }); - return () => { - store.set(targetAtom, (c) => c + 1); - }; - }), - ]) - .flat(1), - cycle(), - complete(), - save({ - folder: import.meta.dirname, - file: 'simple-write', - format: 'json', - }), - save({ - folder: import.meta.dirname, - file: 'simple-write', - format: 'chart.html', - }), - ); -}; - -void main(); diff --git a/benchmarks/store-size-scaling.json b/benchmarks/store-size-scaling.json new file mode 100644 index 0000000..d3c9924 --- /dev/null +++ b/benchmarks/store-size-scaling.json @@ -0,0 +1,159 @@ +{ + "name": "store-size-scaling", + "date": "2026-05-08T23:20:17.975Z", + "version": null, + "results": [ + { + "name": "read atoms=100", + "ops": 6805986, + "margin": 4.35, + "percentSlower": 9.51 + }, + { + "name": "read atoms=1000", + "ops": 6989364, + "margin": 1.95, + "percentSlower": 7.07 + }, + { + "name": "read atoms=10000", + "ops": 7521400, + "margin": 0.99, + "percentSlower": 0 + }, + { + "name": "read atoms=100000", + "ops": 7438652, + "margin": 0.97, + "percentSlower": 1.1 + }, + { + "name": "read atoms=100 [with logger]", + "ops": 2181008, + "margin": 0.89, + "percentSlower": 71 + }, + { + "name": "read atoms=1000 [with logger]", + "ops": 2160714, + "margin": 1.15, + "percentSlower": 71.27 + }, + { + "name": "read atoms=10000 [with logger]", + "ops": 2185179, + "margin": 0.88, + "percentSlower": 70.95 + }, + { + "name": "read atoms=100000 [with logger]", + "ops": 2131260, + "margin": 1.02, + "percentSlower": 71.66 + }, + { + "name": "read atoms=100 [with console formatter]", + "ops": 2190000, + "margin": 0.9, + "percentSlower": 70.88 + }, + { + "name": "read atoms=1000 [with console formatter]", + "ops": 2172670, + "margin": 1.01, + "percentSlower": 71.11 + }, + { + "name": "read atoms=10000 [with console formatter]", + "ops": 2107937, + "margin": 1.2, + "percentSlower": 71.97 + }, + { + "name": "read atoms=100000 [with console formatter]", + "ops": 2134138, + "margin": 0.8, + "percentSlower": 71.63 + }, + { + "name": "write atoms=100", + "ops": 916380, + "margin": 3.58, + "percentSlower": 87.82 + }, + { + "name": "write atoms=1000", + "ops": 926011, + "margin": 3.01, + "percentSlower": 87.69 + }, + { + "name": "write atoms=10000", + "ops": 917115, + "margin": 3.17, + "percentSlower": 87.81 + }, + { + "name": "write atoms=100000", + "ops": 921685, + "margin": 3.16, + "percentSlower": 87.75 + }, + { + "name": "write atoms=100 [with logger]", + "ops": 456959, + "margin": 2.25, + "percentSlower": 93.92 + }, + { + "name": "write atoms=1000 [with logger]", + "ops": 452872, + "margin": 1.8, + "percentSlower": 93.98 + }, + { + "name": "write atoms=10000 [with logger]", + "ops": 392216, + "margin": 4.53, + "percentSlower": 94.79 + }, + { + "name": "write atoms=100000 [with logger]", + "ops": 442339, + "margin": 2.28, + "percentSlower": 94.12 + }, + { + "name": "write atoms=100 [with console formatter]", + "ops": 178200, + "margin": 2.63, + "percentSlower": 97.63 + }, + { + "name": "write atoms=1000 [with console formatter]", + "ops": 181815, + "margin": 1.98, + "percentSlower": 97.58 + }, + { + "name": "write atoms=10000 [with console formatter]", + "ops": 179400, + "margin": 1.87, + "percentSlower": 97.61 + }, + { + "name": "write atoms=100000 [with console formatter]", + "ops": 181280, + "margin": 1.85, + "percentSlower": 97.59 + } + ], + "fastest": { + "name": "read atoms=10000", + "index": 2 + }, + "slowest": { + "name": "write atoms=100 [with console formatter]", + "index": 20 + } +} \ No newline at end of file diff --git a/benchmarks/sub-unsub.json b/benchmarks/sub-unsub.json new file mode 100644 index 0000000..6f81dbe --- /dev/null +++ b/benchmarks/sub-unsub.json @@ -0,0 +1,69 @@ +{ + "name": "sub-unsub", + "date": "2026-05-08T23:08:01.084Z", + "version": null, + "results": [ + { + "name": "sub/unsub 100 atoms", + "ops": 4256, + "margin": 4.89, + "percentSlower": 0 + }, + { + "name": "sub/unsub 100 atoms [with logger]", + "ops": 1691, + "margin": 2.09, + "percentSlower": 60.27 + }, + { + "name": "sub/unsub 100 atoms [with console formatter]", + "ops": 890, + "margin": 1.26, + "percentSlower": 79.09 + }, + { + "name": "sub/unsub 500 atoms", + "ops": 959, + "margin": 0.92, + "percentSlower": 77.47 + }, + { + "name": "sub/unsub 500 atoms [with logger]", + "ops": 374, + "margin": 0.88, + "percentSlower": 91.21 + }, + { + "name": "sub/unsub 500 atoms [with console formatter]", + "ops": 180, + "margin": 1.29, + "percentSlower": 95.77 + }, + { + "name": "sub/unsub 1000 atoms", + "ops": 485, + "margin": 0.91, + "percentSlower": 88.6 + }, + { + "name": "sub/unsub 1000 atoms [with logger]", + "ops": 186, + "margin": 1.12, + "percentSlower": 95.63 + }, + { + "name": "sub/unsub 1000 atoms [with console formatter]", + "ops": 91, + "margin": 1.28, + "percentSlower": 97.86 + } + ], + "fastest": { + "name": "sub/unsub 100 atoms", + "index": 0 + }, + "slowest": { + "name": "sub/unsub 1000 atoms [with console formatter]", + "index": 8 + } +} \ No newline at end of file diff --git a/benchmarks/subscribe-write.json b/benchmarks/subscribe-write.json index b9f5bb5..af2a32a 100644 --- a/benchmarks/subscribe-write.json +++ b/benchmarks/subscribe-write.json @@ -1,55 +1,61 @@ { "name": "subscribe-write", - "date": "2025-06-02T12:12:48.783Z", + "date": "2026-05-08T23:08:51.007Z", "version": null, "results": [ { "name": "atoms=100", - "ops": 1037720, - "margin": 6.79, + "ops": 3980, + "margin": 1.28, "percentSlower": 0 }, { "name": "atoms=100 [with logger]", - "ops": 88471, - "margin": 1.35, - "percentSlower": 91.47 + "ops": 1771, + "margin": 1.07, + "percentSlower": 55.5 + }, + { + "name": "atoms=100 [with console formatter]", + "ops": 901, + "margin": 2.19, + "percentSlower": 77.36 }, { "name": "atoms=1000", - "ops": 988627, - "margin": 5.84, - "percentSlower": 4.73 + "ops": 402, + "margin": 1.47, + "percentSlower": 89.9 }, { "name": "atoms=1000 [with logger]", - "ops": 89388, - "margin": 1.2, - "percentSlower": 91.39 + "ops": 174, + "margin": 1.28, + "percentSlower": 95.63 }, { - "name": "atoms=10000", - "ops": 975355, - "margin": 4.71, - "percentSlower": 6.01 + "name": "atoms=1000 [with console formatter]", + "ops": 92, + "margin": 1.5, + "percentSlower": 97.69 }, { - "name": "atoms=10000 [with logger]", - "ops": 92841, - "margin": 0.76, - "percentSlower": 91.05 + "name": "atoms=10000", + "ops": 38, + "margin": 1.11, + "percentSlower": 99.05 }, { - "name": "atoms=100000", - "ops": 1001656, - "margin": 6.04, - "percentSlower": 3.48 + "name": "atoms=10000 [with logger]", + "ops": 16, + "margin": 3.47, + "percentSlower": 99.6 }, { - "name": "atoms=100000 [with logger]", - "ops": 91279, - "margin": 1.05, - "percentSlower": 91.2 + "name": "atoms=10000 [with console formatter]", + "ops": 9, + "margin": 2.44, + "percentSlower": 99.77 } ], "fastest": { @@ -57,7 +63,7 @@ "index": 0 }, "slowest": { - "name": "atoms=100 [with logger]", - "index": 1 + "name": "atoms=10000 [with console formatter]", + "index": 8 } } \ No newline at end of file diff --git a/benchmarks/subscribe-write.ts b/benchmarks/subscribe-write.ts index 9831fbe..95e4d03 100644 --- a/benchmarks/subscribe-write.ts +++ b/benchmarks/subscribe-write.ts @@ -1,7 +1,8 @@ import { add, complete, cycle, save, suite } from 'benny'; import { type PrimitiveAtom, atom, createStore } from 'jotai'; -import { bindAtomsLoggerToStore } from '../src/bind-atoms-logger-to-store.js'; +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; /** * Taken from Jotai benchmarks : [subscribe-write.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/subscribe-write.ts). @@ -15,13 +16,24 @@ const cleanup = () => { cleanupFns.clear(); }; -const createStateWithAtoms = (n: number, { withLogger }: { withLogger: boolean }) => { +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const createStateWithAtoms = ( + n: number, + { withLogger, withConsoleFormatter }: { withLogger: boolean; withConsoleFormatter?: boolean }, +) => { let targetAtom: PrimitiveAtom | undefined; - const store = createStore(); + let store = createStore(); if (withLogger) { - bindAtomsLoggerToStore(store, { + store = createLoggedStore(store, { synchronous: true, - logger: { log: () => {}, group: () => {}, groupEnd: () => {} }, + formatter: withConsoleFormatter ? consoleFormatter({ logger: silentLogger }) : () => {}, }); } for (let i = 0; i < n; ++i) { @@ -58,6 +70,16 @@ const main = async () => { store.set(targetAtom, (c) => c + 1); }; }), + add(`atoms=${10 ** n} [with console formatter]`, () => { + cleanup(); + const [store, targetAtom] = createStateWithAtoms(10 ** n, { + withLogger: true, + withConsoleFormatter: true, + }); + return () => { + store.set(targetAtom, (c) => c + 1); + }; + }), ]) .flat(1), cycle(), diff --git a/benchmarks/subscription.ts b/benchmarks/subscription.ts new file mode 100644 index 0000000..69f3b36 --- /dev/null +++ b/benchmarks/subscription.ts @@ -0,0 +1,180 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [subscription.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/subscription.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + // Suite 1: Subscribe/unsubscribe churn + await suite( + 'sub-unsub', + ...[100, 500, 1000] + .map((count) => [ + add(`sub/unsub ${count} atoms`, () => { + return () => { + const store = createStore(); + const atoms = []; + for (let i = 0; i < count; i++) atoms.push(atom(i)); + for (const a of atoms) { + const unsub = store.sub(a, () => {}); + unsub(); + } + }; + }), + add(`sub/unsub ${count} atoms [with logger]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const atoms = []; + for (let i = 0; i < count; i++) atoms.push(atom(i)); + for (const a of atoms) { + const unsub = store.sub(a, () => {}); + unsub(); + } + }; + }), + add(`sub/unsub ${count} atoms [with console formatter]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const atoms = []; + for (let i = 0; i < count; i++) atoms.push(atom(i)); + for (const a of atoms) { + const unsub = store.sub(a, () => {}); + unsub(); + } + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'sub-unsub', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'sub-unsub', + format: 'chart.html', + }), + ); + + // Suite 2: Write with all atoms subscribed + await suite( + 'subscribe-write', + ...[2, 3, 4] + .map((n) => [ + add(`atoms=${10 ** n}`, () => { + const store = createStore(); + const target = atom(0); + const unsubs: (() => void)[] = []; + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.get(a); + unsubs.push( + store.sub(a, () => { + store.get(a); + }), + ); + } + return { + fn: () => { + store.set(target, (c) => c + 1); + }, + teardown: () => { + unsubs.forEach((u) => { + u(); + }); + }, + }; + }), + add(`atoms=${10 ** n} [with logger]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const target = atom(0); + const unsubs: (() => void)[] = []; + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.get(a); + unsubs.push( + store.sub(a, () => { + store.get(a); + }), + ); + } + return { + fn: () => { + store.set(target, (c) => c + 1); + }, + teardown: () => { + unsubs.forEach((u) => { + u(); + }); + }, + }; + }), + add(`atoms=${10 ** n} [with console formatter]`, () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const target = atom(0); + const unsubs: (() => void)[] = []; + for (let i = 0; i < 10 ** n; ++i) { + const a = atom(i); + store.get(a); + unsubs.push( + store.sub(a, () => { + store.get(a); + }), + ); + } + return { + fn: () => { + store.set(target, (c) => c + 1); + }, + teardown: () => { + unsubs.forEach((u) => { + u(); + }); + }, + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'subscribe-write', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'subscribe-write', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/benchmarks/wide-fan-out.json b/benchmarks/wide-fan-out.json new file mode 100644 index 0000000..55b9bb4 --- /dev/null +++ b/benchmarks/wide-fan-out.json @@ -0,0 +1,69 @@ +{ + "name": "wide-fan-out", + "date": "2026-05-08T23:07:11.387Z", + "version": null, + "results": [ + { + "name": "1→100 derived", + "ops": 1746, + "margin": 5.9, + "percentSlower": 0 + }, + { + "name": "1→100 derived [with logger]", + "ops": 394, + "margin": 3.62, + "percentSlower": 77.43 + }, + { + "name": "1→100 derived [with console formatter]", + "ops": 242, + "margin": 3.03, + "percentSlower": 86.14 + }, + { + "name": "1→500 derived", + "ops": 248, + "margin": 3.33, + "percentSlower": 85.8 + }, + { + "name": "1→500 derived [with logger]", + "ops": 42, + "margin": 3.6, + "percentSlower": 97.59 + }, + { + "name": "1→500 derived [with console formatter]", + "ops": 29, + "margin": 5.68, + "percentSlower": 98.34 + }, + { + "name": "1→1000 derived", + "ops": 123, + "margin": 4.23, + "percentSlower": 92.96 + }, + { + "name": "1→1000 derived [with logger]", + "ops": 12, + "margin": 5.47, + "percentSlower": 99.31 + }, + { + "name": "1→1000 derived [with console formatter]", + "ops": 10, + "margin": 5.69, + "percentSlower": 99.43 + } + ], + "fastest": { + "name": "1→100 derived", + "index": 0 + }, + "slowest": { + "name": "1→1000 derived [with console formatter]", + "index": 8 + } +} \ No newline at end of file diff --git a/benchmarks/wide-fan-out.ts b/benchmarks/wide-fan-out.ts new file mode 100644 index 0000000..e92e864 --- /dev/null +++ b/benchmarks/wide-fan-out.ts @@ -0,0 +1,92 @@ +import { add, complete, cycle, save, suite } from 'benny'; +import { atom, createStore } from 'jotai'; + +import { consoleFormatter } from '../dist/formatters/console.js'; +import { createLoggedStore } from '../dist/vanilla/create-logged-store.js'; + +/** + * Taken from Jotai benchmarks : [wide-fan-out.ts](https://github.com/pmndrs/jotai/blob/main/benchmarks/wide-fan-out.ts). + */ + +const silentLogger = { + log: () => {}, + group: () => {}, + groupCollapsed: () => {}, + groupEnd: () => {}, + warn: () => {}, +}; + +const main = async () => { + await suite( + 'wide-fan-out', + ...[100, 500, 1000] + .map((width) => [ + add(`1→${width} derived`, () => { + return () => { + const store = createStore(); + const base = atom(0); + const derived = []; + for (let i = 0; i < width; i++) { + derived.push(atom((get) => get(base) + i)); + } + const unsubs = derived.map((d) => store.sub(d, () => {})); + store.set(base, 1); + unsubs.forEach((u) => { + u(); + }); + }; + }), + add(`1→${width} derived [with logger]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: () => {}, + }); + const base = atom(0); + const derived = []; + for (let i = 0; i < width; i++) { + derived.push(atom((get) => get(base) + i)); + } + const unsubs = derived.map((d) => store.sub(d, () => {})); + store.set(base, 1); + unsubs.forEach((u) => { + u(); + }); + }; + }), + add(`1→${width} derived [with console formatter]`, () => { + return () => { + const store = createLoggedStore(createStore(), { + synchronous: true, + formatter: consoleFormatter({ logger: silentLogger }), + }); + const base = atom(0); + const derived = []; + for (let i = 0; i < width; i++) { + derived.push(atom((get) => get(base) + i)); + } + const unsubs = derived.map((d) => store.sub(d, () => {})); + store.set(base, 1); + unsubs.forEach((u) => { + u(); + }); + }; + }), + ]) + .flat(1), + cycle(), + complete(), + save({ + folder: import.meta.dirname, + file: 'wide-fan-out', + format: 'json', + }), + save({ + folder: import.meta.dirname, + file: 'wide-fan-out', + format: 'chart.html', + }), + ); +}; + +void main(); diff --git a/package.json b/package.json index 37eb373..ddf0d04 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,12 @@ "benny": "3.7.1", "eslint": "10.2.1", "jiti": "2.6.1", - "jotai": "2.19.1", - "jotai-devtools": "0.13.1", + "jotai": "2.20.0", + "jotai-devtools": "0.14.0", "jotai-family": "1.0.1", "jotai-loadable": "1.0.0", "jsdom": "29.1.1", + "pkg-pr-new": "0.0.71", "prettier": "3.8.3", "publint": "0.3.18", "react": "19.2.5", @@ -64,9 +65,15 @@ "dev": "vitest", "local-release": "changeset version && changeset publish", "prepublishOnly": "npm run ci", - "benchmark": "pnpm run benchmark:simple-read && pnpm run benchmark:simple-write && pnpm run benchmark:subscribe-write", - "benchmark:simple-read": "tsx benchmarks/simple-read.ts", - "benchmark:simple-write": "tsx benchmarks/simple-write.ts", + "benchmark": "pnpm run benchmark:atom-creation && pnpm run benchmark:computed-read && pnpm run benchmark:derived-chain && pnpm run benchmark:diamond && pnpm run benchmark:wide-fan-out && pnpm run benchmark:subscription && pnpm run benchmark:read-write && pnpm run benchmark:select-atom && pnpm run benchmark:subscribe-write", + "benchmark:atom-creation": "tsx benchmarks/atom-creation.ts", + "benchmark:computed-read": "tsx benchmarks/computed-read.ts", + "benchmark:derived-chain": "tsx benchmarks/derived-chain.ts", + "benchmark:diamond": "tsx benchmarks/diamond.ts", + "benchmark:wide-fan-out": "tsx benchmarks/wide-fan-out.ts", + "benchmark:subscription": "tsx benchmarks/subscription.ts", + "benchmark:read-write": "tsx benchmarks/read-write.ts", + "benchmark:select-atom": "tsx benchmarks/select-atom.ts", "benchmark:subscribe-write": "tsx benchmarks/subscribe-write.ts", "benchmark:inspect": "tsx --inspect-brk ./benchmarks/inspect-me.ts" }, @@ -75,12 +82,37 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./vanilla": { + "types": "./dist/vanilla.d.ts", + "import": "./dist/vanilla.js" + }, + "./react": { + "types": "./dist/react.d.ts", + "import": "./dist/react.js" + }, + "./formatters/console": { + "types": "./dist/formatters/console.d.ts", + "import": "./dist/formatters/console.js" + } + }, + "typesVersions": { + "*": { + "vanilla": [ + "./dist/vanilla.d.ts" + ], + "react": [ + "./dist/react.d.ts" + ], + "formatters/console": [ + "./dist/formatters/console.d.ts" + ] } }, "peerDependencies": { "@types/react": ">=17.0.0", - "jotai": ">=2.18.0", - "jotai-devtools": ">=0.13.0", + "jotai": ">=2.20.0", + "jotai-devtools": ">=0.14.0", "react": ">=17.0.0" }, "peerDependenciesMeta": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e59e8f6..4e9f5e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,20 +48,23 @@ importers: specifier: 2.6.1 version: 2.6.1 jotai: - specifier: 2.19.1 - version: 2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) + specifier: 2.20.0 + version: 2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) jotai-devtools: - specifier: 0.13.1 - version: 0.13.1(@types/react@19.2.14)(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(redux@5.0.1) + specifier: 0.14.0 + version: 0.14.0(@types/react@19.2.14)(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(redux@5.0.1) jotai-family: specifier: 1.0.1 - version: 1.0.1(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)) + version: 1.0.1(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)) jotai-loadable: specifier: 1.0.0 - version: 1.0.0(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)) + version: 1.0.0(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)) jsdom: specifier: 29.1.1 version: 29.1.1 + pkg-pr-new: + specifier: 0.0.71 + version: 0.0.71 prettier: specifier: 3.8.3 version: 3.8.3 @@ -1469,11 +1472,11 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jotai-devtools@0.13.1: - resolution: {integrity: sha512-nd7W5+Pcf/W6d9wNVX+ao4qkjdLUBTbgSXIeQ9+SEvQ79dU2qrqZ/3TkKWC9Ie7gss+En6mxkU70iuuLJS2cMg==} + jotai-devtools@0.14.0: + resolution: {integrity: sha512-vtUE3hlcZJeBpaziPMu07jB0kWu5MWUsGAsYBUUFBbTcE8Tzumb2+ZV6lPrIgEAJmVg5ZD1vf73URDzdgoKZaw==} engines: {node: '>=14.0.0'} peerDependencies: - jotai: '>=2.14.0' + jotai: '>=2.20.0' react: '>=17.0.0' jotai-family@1.0.1: @@ -1487,8 +1490,8 @@ packages: peerDependencies: jotai: ^2.3.0 - jotai@2.19.1: - resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} + jotai@2.20.0: + resolution: {integrity: sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg==} engines: {node: '>=12.20.0'} peerDependencies: '@babel/core': '>=7.0.0' @@ -1552,7 +1555,6 @@ packages: resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==} engines: {node: '>=8.17.0'} hasBin: true - bundledDependencies: [] jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1801,6 +1803,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkg-pr-new@0.0.71: + resolution: {integrity: sha512-Ln7I7NaOXN6CjBLVrNqsWOo6mIaNW4chH4eCR2t4tnQSmYtyQiWCqlMG6bL2JYCT1MOiKEiDObnnzePGh0TNFQ==} + hasBin: true + platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} @@ -3777,7 +3783,7 @@ snapshots: jiti@2.6.1: {} - jotai-devtools@0.13.1(@types/react@19.2.14)(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(redux@5.0.1): + jotai-devtools@0.14.0(@types/react@19.2.14)(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(redux@5.0.1): dependencies: '@mantine/code-highlight': 7.17.4(@mantine/core@7.17.4(@mantine/hooks@7.17.4(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@7.17.4(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@mantine/core': 7.17.4(@mantine/hooks@7.17.4(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -3785,7 +3791,7 @@ snapshots: '@redux-devtools/extension': 3.3.0(redux@5.0.1) clsx: 2.1.1 javascript-stringify: 2.1.0 - jotai: 2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) + jotai: 2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) jsondiffpatch: 0.5.0 react: 19.2.5 react-base16-styling: 0.9.1 @@ -3797,15 +3803,15 @@ snapshots: - react-dom - redux - jotai-family@1.0.1(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)): + jotai-family@1.0.1(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)): dependencies: - jotai: 2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) + jotai: 2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) - jotai-loadable@1.0.0(jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)): + jotai-loadable@1.0.0(jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5)): dependencies: - jotai: 2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) + jotai: 2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5) - jotai@2.19.1(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): + jotai@2.20.0(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5): optionalDependencies: '@babel/template': 7.28.6 '@types/react': 19.2.14 @@ -4089,6 +4095,8 @@ snapshots: pify@4.0.1: {} + pkg-pr-new@0.0.71: {} + platform@1.3.6: {} postcss@8.5.13: diff --git a/src/bind-atoms-logger-to-store.ts b/src/bind-atoms-logger-to-store.ts deleted file mode 100644 index 066bdb2..0000000 --- a/src/bind-atoms-logger-to-store.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - INTERNAL_getBuildingBlocksRev2, - INTERNAL_initializeStoreHooksRev2, - type INTERNAL_BuildingBlocks, -} from 'jotai/vanilla/internals'; - -import { getOnAtomGarbageCollected } from './callbacks/on-atom-garbage-collected.js'; -import { getOnAtomMounted } from './callbacks/on-atom-mounted.js'; -import { getOnAtomStateMapSet as onAtomStateMapSet } from './callbacks/on-atom-state-map-set.js'; -import { getOnAtomUnmounted } from './callbacks/on-atom-unmounted.js'; -import { getOnStoreGet } from './callbacks/on-store-get.js'; -import { getOnStoreSet } from './callbacks/on-store-set.js'; -import { getOnStoreSub } from './callbacks/on-store-sub.js'; -import { ATOMS_LOGGER_SYMBOL } from './consts/atom-logger-symbol.js'; -import { createLogTransactionsScheduler } from './log-transactions-scheduler.js'; -import type { AtomsLoggerOptions, Store, StoreWithAtomsLogger } from './types/atoms-logger.js'; -import { atomsLoggerOptionsToState } from './utils/logger-options-to-state.js'; - -export function bindAtomsLoggerToStore( - store: Store, - options?: AtomsLoggerOptions, -): store is StoreWithAtomsLogger { - const newStateOptions = atomsLoggerOptionsToState(options); - - if (isAtomsLoggerBoundToStore(store)) { - Object.assign(store[ATOMS_LOGGER_SYMBOL], newStateOptions); - return true; - } - - let buildingBlocks: Readonly; - try { - buildingBlocks = INTERNAL_getBuildingBlocksRev2(store); - } catch (error) { - newStateOptions.logger.log('Fail to bind atoms logger to', store, ':', error); - return false; - } - - const storeWithAtomsLogger = store as StoreWithAtomsLogger; - - const prevStoreGet = store.get; - const prevStoreSet = store.set; - const prevStoreSub = store.sub; - - store.get = getOnStoreGet(storeWithAtomsLogger); - store.set = getOnStoreSet(storeWithAtomsLogger); - store.sub = getOnStoreSub(storeWithAtomsLogger); - - const atomsFinalizationRegistry = new FinalizationRegistry( - getOnAtomGarbageCollected(storeWithAtomsLogger), - ); - - const atomStateMap = buildingBlocks[0]; - const mountedMap = buildingBlocks[1]; - - const prevAtomStateMapSet = atomStateMap.set.bind(atomStateMap); - atomStateMap.set = onAtomStateMapSet(storeWithAtomsLogger); - - const storeHooks = INTERNAL_initializeStoreHooksRev2(buildingBlocks[6]); - storeHooks.m.add(undefined, getOnAtomMounted(storeWithAtomsLogger)); - storeHooks.u.add(undefined, getOnAtomUnmounted(storeWithAtomsLogger)); - - const getState = atomStateMap.get.bind(atomStateMap); - const getMounted = mountedMap.get.bind(mountedMap); - - const logTransactionsScheduler = createLogTransactionsScheduler(storeWithAtomsLogger); - - storeWithAtomsLogger[ATOMS_LOGGER_SYMBOL] = { - ...newStateOptions, - registerAbortHandler: buildingBlocks[26], - prevStoreGet, - prevStoreSet, - prevStoreSub, - prevAtomStateMapSet, - getState, - getMounted, - logTransactionsScheduler, - transactionNumber: 1, - currentTransaction: undefined, - isInsideTransaction: false, - atomsFinalizationRegistry, - promisesResultsMap: new WeakMap(), - dependenciesMap: new WeakMap(), - prevTransactionDependenciesMap: new WeakMap(), - transactionsDebounceTimeoutId: undefined, - maxWidths: { - eventsCount: 0, - elapsedTime: 0, - }, - }; - - return true; -} - -export function isAtomsLoggerBoundToStore(store: Store): store is StoreWithAtomsLogger { - return ATOMS_LOGGER_SYMBOL in store; -} diff --git a/src/callbacks/on-atom-garbage-collected.ts b/src/callbacks/on-atom-garbage-collected.ts deleted file mode 100644 index 6395b07..0000000 --- a/src/callbacks/on-atom-garbage-collected.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; -import { - AtomsLoggerEventTypes, - type AtomId, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; - -export function getOnAtomGarbageCollected(store: StoreWithAtomsLogger) { - return function onAtomGarbageCollected(atom: AtomId): void { - addEventToTransaction(store, { type: AtomsLoggerEventTypes.destroyed, atom }); - }; -} diff --git a/src/callbacks/on-atom-mounted.ts b/src/callbacks/on-atom-mounted.ts deleted file mode 100644 index 39b54be..0000000 --- a/src/callbacks/on-atom-mounted.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; -import { - AtomsLoggerEventTypes, - type AnyAtom, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; -import { getAtomValue } from '../utils/get-atom-value.js'; - -export function getOnAtomMounted(store: StoreWithAtomsLogger) { - return function onAtomMounted(atom: AnyAtom) { - const { hasValue, value } = getAtomValue(store, atom); - if (hasValue) { - addEventToTransaction(store, { type: AtomsLoggerEventTypes.mounted, atom, value }); - } else { - addEventToTransaction(store, { type: AtomsLoggerEventTypes.mounted, atom }); - } - }; -} diff --git a/src/callbacks/on-atom-state-map-set.ts b/src/callbacks/on-atom-state-map-set.ts deleted file mode 100644 index 66d1549..0000000 --- a/src/callbacks/on-atom-state-map-set.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { type INTERNAL_AtomState } from 'jotai/vanilla/internals'; - -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; -import { - AtomsLoggerEventTypes, - type AnyAtom, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; -import { shouldShowAtom } from '../utils/should-show-atom.js'; -import { onAtomValueChanged } from './on-atom-value-changed.js'; - -export function getOnAtomStateMapSet(store: StoreWithAtomsLogger) { - return function onAtomStateMapSet(atom: AnyAtom, atomState: INTERNAL_AtomState): void { - let isInitialValue = true; - - if (shouldShowAtom(store, atom)) { - store[ATOMS_LOGGER_SYMBOL].atomsFinalizationRegistry.register(atom, atom.toString()); - // In jotai 2.17.x, d.clear() was called at the start of each atom read, which initialized - // dependenciesMap for every visible atom (even those with no deps). In jotai 2.18+, - // d.clear() is gone, so we initialize it here when the atom state is first created. - /* v8 ignore next 3 -- atom state is created only once per atom per store instance -- @preserve */ - if (!store[ATOMS_LOGGER_SYMBOL].dependenciesMap.has(atom)) { - store[ATOMS_LOGGER_SYMBOL].dependenciesMap.set(atom, new Set()); - } - } - - // Track the dependencies changes in the dependency map. - const originalMapSet = atomState.d.set.bind(atomState.d); - atomState.d.set = function mapSetProxy(addedDependency: AnyAtom, epochNumber: number) { - const result = originalMapSet(addedDependency, epochNumber); - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.dependenciesChanged, - atom, - addedDependency, - }); - return result; - }; - /* v8 ignore start -- clear is not used anymore in jotai 2.18+ -- @preserve */ - const originalMapClear = atomState.d.clear.bind(atomState.d); - atomState.d.clear = function mapClearProxy() { - originalMapClear(); - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.dependenciesChanged, - atom, - clearedDependencies: true, - }); - }; - /* v8 ignore end -- @preserve */ - const originalMapDelete = atomState.d.delete.bind(atomState.d); - atomState.d.delete = function mapDeleteProxy(removedDependency: AnyAtom) { - const result = originalMapDelete(removedDependency); - if (result) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.dependenciesChanged, - atom, - removedDependency, - }); - } - return result; - }; - - // Track the values changes in the atom state. - const stateProxy = new Proxy(atomState, { - set(target, _prop, newValue: unknown, receiver) { - const prop = _prop as keyof typeof target; - if (prop === 'v') { - const oldValue = Reflect.get(target, prop, receiver); - onAtomValueChanged(store, atom, { isInitialValue, oldValue, newValue }); - isInitialValue = false; - } - return Reflect.set(target, prop, newValue); - }, - }); - - store[ATOMS_LOGGER_SYMBOL].prevAtomStateMapSet(atom, stateProxy); - }; -} diff --git a/src/callbacks/on-atom-unmounted.ts b/src/callbacks/on-atom-unmounted.ts deleted file mode 100644 index 31e6e9a..0000000 --- a/src/callbacks/on-atom-unmounted.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; -import { - AtomsLoggerEventTypes, - type AnyAtom, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; - -export function getOnAtomUnmounted(store: StoreWithAtomsLogger) { - return function onAtomUnmounted(atom: AnyAtom) { - addEventToTransaction(store, { type: AtomsLoggerEventTypes.unmounted, atom }); - }; -} diff --git a/src/callbacks/on-store-get.ts b/src/callbacks/on-store-get.ts deleted file mode 100644 index 982a91b..0000000 --- a/src/callbacks/on-store-get.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Atom } from 'jotai'; - -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { endTransaction } from '../transactions/end-transaction.js'; -import { startTransaction } from '../transactions/start-transaction.js'; -import { AtomsLoggerTransactionTypes, type StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function getOnStoreGet(store: StoreWithAtomsLogger): StoreWithAtomsLogger['get'] { - return function onStoreGet(atom: Atom): TValue { - const doStartTransaction = !store[ATOMS_LOGGER_SYMBOL].isInsideTransaction; - try { - if (doStartTransaction) { - startTransaction(store, { type: AtomsLoggerTransactionTypes.storeGet, atom }); - } - return store[ATOMS_LOGGER_SYMBOL].prevStoreGet(atom); - } finally { - if (doStartTransaction) { - endTransaction(store); - } - } - }; -} diff --git a/src/callbacks/on-store-set.ts b/src/callbacks/on-store-set.ts deleted file mode 100644 index a07dffc..0000000 --- a/src/callbacks/on-store-set.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { WritableAtom } from 'jotai'; - -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { endTransaction } from '../transactions/end-transaction.js'; -import { startTransaction } from '../transactions/start-transaction.js'; -import { AtomsLoggerTransactionTypes, type StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function getOnStoreSet(store: StoreWithAtomsLogger): StoreWithAtomsLogger['set'] { - return function onStoreSet( - atom: WritableAtom, - ...args: TArgs - ) { - const doStartTransaction = !store[ATOMS_LOGGER_SYMBOL].isInsideTransaction; - try { - const transaction = { - type: AtomsLoggerTransactionTypes.storeSet, - atom, - args, - result: undefined as unknown, - }; - if (doStartTransaction) { - startTransaction(store, transaction); - } - const result = store[ATOMS_LOGGER_SYMBOL].prevStoreSet(atom, ...args); - transaction.result = result; - return result; - } finally { - if (doStartTransaction) { - endTransaction(store); - } - } - }; -} diff --git a/src/callbacks/on-store-sub.ts b/src/callbacks/on-store-sub.ts deleted file mode 100644 index b9d3215..0000000 --- a/src/callbacks/on-store-sub.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { endTransaction } from '../transactions/end-transaction.js'; -import { startTransaction } from '../transactions/start-transaction.js'; -import { - AtomsLoggerTransactionTypes, - type AnyAtom, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; - -export function getOnStoreSub(store: StoreWithAtomsLogger): StoreWithAtomsLogger['sub'] { - return function onStoreSub(atom: AnyAtom, listener: () => void): () => void { - const doStartTransaction = !store[ATOMS_LOGGER_SYMBOL].isInsideTransaction; - try { - if (doStartTransaction) { - startTransaction(store, { - type: AtomsLoggerTransactionTypes.storeSubscribe, - atom, - listener, - }); - } - const unsubscribe = store[ATOMS_LOGGER_SYMBOL].prevStoreSub(atom, listener); - return getOnStoreUnsubscribe(store, atom, listener, unsubscribe); - } finally { - if (doStartTransaction) { - endTransaction(store); - } - } - }; -} - -function getOnStoreUnsubscribe( - store: StoreWithAtomsLogger, - atom: AnyAtom, - listener: () => void, - unsubscribe: () => void, -): ReturnType { - return function onStoreUnsubscribe() { - const doStartTransaction = !store[ATOMS_LOGGER_SYMBOL].isInsideTransaction; - try { - if (doStartTransaction) { - { - startTransaction(store, { - type: AtomsLoggerTransactionTypes.storeUnsubscribe, - atom, - listener, - }); - } - } - unsubscribe(); - } finally { - if (doStartTransaction) { - endTransaction(store); - } - } - }; -} diff --git a/src/consts/atom-logger-symbol.ts b/src/consts/atom-logger-symbol.ts deleted file mode 100644 index 43d4645..0000000 --- a/src/consts/atom-logger-symbol.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Symbol used to bind the atoms logger to the store. - */ -export const ATOMS_LOGGER_SYMBOL = Symbol('atoms-logger'); diff --git a/src/formatters/console.ts b/src/formatters/console.ts new file mode 100644 index 0000000..9caf4be --- /dev/null +++ b/src/formatters/console.ts @@ -0,0 +1,2 @@ +export { consoleFormatter } from './console/index.js'; +export type { ConsoleFormatterOptions } from './console/types.js'; diff --git a/src/log-atom-event/add-atom-to-logs.ts b/src/formatters/console/add-atom-to-logs.ts similarity index 94% rename from src/log-atom-event/add-atom-to-logs.ts rename to src/formatters/console/add-atom-to-logs.ts index d57711c..8a9ed0a 100644 --- a/src/log-atom-event/add-atom-to-logs.ts +++ b/src/formatters/console/add-atom-to-logs.ts @@ -1,6 +1,6 @@ -import type { DEFAULT_ATOMS_LOGGER_COLORS } from '../consts/colors.js'; -import type { AnyAtom, AtomId } from '../types/atoms-logger.js'; +import type { AnyAtom, AtomId } from '../../vanilla/types/event.js'; import { addToLogs } from './add-to-logs.js'; +import type { DEFAULT_ATOMS_LOGGER_COLORS } from './consts/colors.js'; export function addAtomToLogs( logs: unknown[], diff --git a/src/log-atom-event/add-dash-to-logs.ts b/src/formatters/console/add-dash-to-logs.ts similarity index 100% rename from src/log-atom-event/add-dash-to-logs.ts rename to src/formatters/console/add-dash-to-logs.ts diff --git a/src/log-atom-event/add-to-logs.ts b/src/formatters/console/add-to-logs.ts similarity index 99% rename from src/log-atom-event/add-to-logs.ts rename to src/formatters/console/add-to-logs.ts index e9d1f5e..58b817f 100644 --- a/src/log-atom-event/add-to-logs.ts +++ b/src/formatters/console/add-to-logs.ts @@ -2,7 +2,7 @@ import { DEFAULT_ATOMS_LOGGER_COLORS, DEFAULT_ATOMS_LOGGER_DARK_COLORS, DEFAULT_ATOMS_LOGGER_LIGHT_COLORS, -} from '../consts/colors.js'; +} from './consts/colors.js'; const COLORS_BY_SCHEME = { default: DEFAULT_ATOMS_LOGGER_COLORS, diff --git a/src/utils/logger-options-to-state.ts b/src/formatters/console/console-formatter-options-to-state.ts similarity index 57% rename from src/utils/logger-options-to-state.ts rename to src/formatters/console/console-formatter-options-to-state.ts index c6550da..5254df7 100644 --- a/src/utils/logger-options-to-state.ts +++ b/src/formatters/console/console-formatter-options-to-state.ts @@ -1,14 +1,10 @@ -import type { AtomsLoggerOptions, AtomsLoggerOptionsInState } from '../types/atoms-logger.js'; +import type { ConsoleFormatterOptions, ConsoleFormatterState } from './types.js'; -// eslint-disable-next-line complexity -- it's a simple conversion function -export function atomsLoggerOptionsToState( - options: AtomsLoggerOptions = {}, -): AtomsLoggerOptionsInState { +export function consoleFormatterOptionsToState( + options: ConsoleFormatterOptions = {}, +): ConsoleFormatterState { const { - enabled = true, domain, - shouldShowPrivateAtoms = false, - shouldShowAtom, logger = console, groupTransactions = true, groupEvents = false, @@ -26,18 +22,10 @@ export function atomsLoggerOptionsToState( collapseTransactions = true, collapseEvents = false, ownerStackLimit = 2, - getOwnerStack, - getComponentDisplayName, - synchronous = false, - transactionDebounceMs = 250, - requestIdleCallbackTimeoutMs = 250, - maxProcessingTimeMs = 16, } = options; + return { - enabled, domain, - shouldShowPrivateAtoms, - shouldShowAtom, logger, groupTransactions, groupEvents, @@ -59,10 +47,9 @@ export function atomsLoggerOptionsToState( collapseTransactions, collapseEvents, ownerStackLimit, - getOwnerStack, - getComponentDisplayName, - transactionDebounceMs: synchronous ? -1 : transactionDebounceMs, - requestIdleCallbackTimeoutMs: synchronous ? -1 : requestIdleCallbackTimeoutMs, - maxProcessingTimeMs: synchronous ? -1 : maxProcessingTimeMs, + maxWidths: { + eventsCount: 0, + elapsedTime: 0, + }, }; } diff --git a/src/formatters/console/console-formatter.ts b/src/formatters/console/console-formatter.ts new file mode 100644 index 0000000..81d413e --- /dev/null +++ b/src/formatters/console/console-formatter.ts @@ -0,0 +1,24 @@ +import type { AtomLoggerFormatter } from '../../vanilla/types/formatter.js'; +import { consoleFormatterOptionsToState } from './console-formatter-options-to-state.js'; +import { logTransaction } from './log-transaction.js'; +import type { ConsoleFormatterOptions, ConsoleFormatterState } from './types.js'; + +/** + * Creates a console formatter that logs atom transactions to the browser/Node console. + * + * @example + * ```ts + * import { createLoggedStore } from 'jotai-logger/vanilla'; + * import { consoleFormatter } from 'jotai-logger/formatters/console'; + * + * const store = createLoggedStore(parentStore, { + * formatter: consoleFormatter({ colorScheme: 'dark' }), + * }); + * ``` + */ +export function consoleFormatter(options?: ConsoleFormatterOptions): AtomLoggerFormatter { + const state: ConsoleFormatterState = consoleFormatterOptionsToState(options); + return (transaction) => { + logTransaction(transaction, state); + }; +} diff --git a/src/consts/colors.ts b/src/formatters/console/consts/colors.ts similarity index 100% rename from src/consts/colors.ts rename to src/formatters/console/consts/colors.ts diff --git a/src/log-atom-event/event-log-pipeline.ts b/src/formatters/console/event-log-pipeline.ts similarity index 76% rename from src/log-atom-event/event-log-pipeline.ts rename to src/formatters/console/event-log-pipeline.ts index ea3c3d9..31acfda 100644 --- a/src/log-atom-event/event-log-pipeline.ts +++ b/src/formatters/console/event-log-pipeline.ts @@ -1,32 +1,29 @@ -import { - AtomsLoggerEventTypes, - type AtomsLoggerEvent, - type AtomsLoggerEventType, - type AtomsLoggerState, -} from '../types/atoms-logger.js'; -import { stringifyValue } from '../utils/stringify-value.js'; +import { AtomEventTypes, type AtomEvent, type AtomEventType } from '../../vanilla/types/event.js'; +import { shouldSetStateInEvent } from '../../vanilla/utils/should-set-state-in-event.js'; import { addAtomToLogs } from './add-atom-to-logs.js'; import { addToLogs } from './add-to-logs.js'; import { LogPipeline } from './log-pipeline.js'; +import type { ConsoleFormatterState } from './types.js'; +import { stringifyValue } from './utils/stringify-value.js'; -const addEventTypeToLogsMapping: Record[2]> = { - [AtomsLoggerEventTypes.initialized]: { +const addEventTypeToLogsMapping: Record[2]> = { + [AtomEventTypes.initialized]: { plainText: () => 'initialized value of', formatted: () => ['%cinitialized value %cof', ['blue', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.changed]: { + [AtomEventTypes.changed]: { plainText: () => 'changed value of', formatted: () => ['%cchanged value %cof', ['lightBlue', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.initialPromisePending]: { + [AtomEventTypes.initialPromisePending]: { plainText: () => 'pending initial promise of', formatted: () => ['%cpending initial promise %cof', ['pink', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.changedPromisePending]: { + [AtomEventTypes.changedPromisePending]: { plainText: () => 'pending promise of', formatted: () => ['%cpending promise %cof', ['pink', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.initialPromiseResolved]: { + [AtomEventTypes.initialPromiseResolved]: { plainText: () => 'resolved initial promise of', formatted: () => [ '%cresolved %cinitial promise %cof', @@ -35,11 +32,11 @@ const addEventTypeToLogsMapping: Record 'resolved promise of', formatted: () => ['%cresolved %cpromise %cof', ['green', 'bold'], ['pink', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.initialPromiseRejected]: { + [AtomEventTypes.initialPromiseRejected]: { plainText: () => 'rejected initial promise of', formatted: () => [ '%crejected %cinitial promise %cof', @@ -48,11 +45,11 @@ const addEventTypeToLogsMapping: Record 'rejected promise of', formatted: () => ['%crejected %cpromise %cof', ['red', 'bold'], ['pink', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.initialPromiseAborted]: { + [AtomEventTypes.initialPromiseAborted]: { plainText: () => 'aborted initial promise of', formatted: () => [ '%caborted %cinitial promise %cof', @@ -61,23 +58,23 @@ const addEventTypeToLogsMapping: Record 'aborted promise of', formatted: () => ['%caborted %cpromise %cof', ['red', 'bold'], ['pink', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.destroyed]: { + [AtomEventTypes.destroyed]: { plainText: () => 'destroyed', formatted: () => ['%cdestroyed', ['red', 'bold']], }, - [AtomsLoggerEventTypes.dependenciesChanged]: { + [AtomEventTypes.dependenciesChanged]: { plainText: () => 'changed dependencies of', formatted: () => ['%cchanged dependencies %cof', ['yellow', 'bold'], 'grey'], }, - [AtomsLoggerEventTypes.mounted]: { + [AtomEventTypes.mounted]: { plainText: () => 'mounted', formatted: () => ['%cmounted', ['green', 'bold']], }, - [AtomsLoggerEventTypes.unmounted]: { + [AtomEventTypes.unmounted]: { plainText: () => 'unmounted', formatted: () => ['%cunmounted', ['red', 'bold']], }, @@ -85,8 +82,9 @@ const addEventTypeToLogsMapping: Record() // Initialize base logging context @@ -111,13 +109,13 @@ export const EventLogPipeline = new LogPipeline() | { hasOldValue?: undefined; oldValue?: undefined; isOldValueError?: undefined } ) >(function addOldValuesToEventMeta(context) { - const { event } = context; - if ('oldValues' in event && event.oldValues !== undefined && event.oldValues.length > 0) { + const { event, mergedOldValues } = context; + if (mergedOldValues !== undefined && mergedOldValues.length > 0) { context.hasOldValues = true; - context.oldValues = event.oldValues; + context.oldValues = mergedOldValues; context.hasOldValue = true; - context.oldValue = event.oldValues[0]; - context.isOldValueError = event.oldValues[0] instanceof Error; + context.oldValue = mergedOldValues[0]; + context.isOldValueError = mergedOldValues[0] instanceof Error; } else if ('oldValue' in event) { context.hasOldValue = true; context.oldValue = event.oldValue; @@ -146,7 +144,7 @@ export const EventLogPipeline = new LogPipeline() context.newValue = event.error; context.isNewValueError = true; } - context.showNewValueInLog = event.type !== AtomsLoggerEventTypes.mounted; + context.showNewValueInLog = event.type !== AtomEventTypes.mounted; }) // {event} @@ -282,41 +280,34 @@ export const EventLogPipeline = new LogPipeline() const { pendingPromises, dependencies, dependents } = event; - const showPendingPromises = pendingPromises && pendingPromises.length > 0; - const showDependents = dependents && dependents.length > 0; - - const showOldDependencies = event.type === AtomsLoggerEventTypes.dependenciesChanged; - const showNewDependencies = showOldDependencies; - - let oldDependencies = showOldDependencies ? event.oldDependencies : undefined; - let newDependencies = dependencies; + const showPendingPromises = pendingPromises && pendingPromises.size > 0; + const showDependents = dependents && dependents.size > 0; - const showDependencies = !showNewDependencies && dependencies && dependencies.size > 0; + const isDepsChangedEvent = event.type === AtomEventTypes.dependenciesChanged; + const showDependencies = !isDepsChangedEvent && dependencies && dependencies.size > 0; if (showPendingPromises) { - subLogsArray.push(['pending promises', pendingPromises]); - subLogsObject.pendingPromises = pendingPromises; + const pendingPromisesArray = Array.from(pendingPromises, (a) => a.toString()); + subLogsArray.push(['pending promises', pendingPromisesArray]); + subLogsObject.pendingPromises = pendingPromisesArray; } - if (showOldDependencies) { - oldDependencies ??= new Set(); - const oldDependenciesArray = Array.from(oldDependencies); + if (isDepsChangedEvent) { + const oldDependenciesArray = Array.from(event.oldDependencies ?? [], (a) => a.toString()); + const newDependenciesArray = Array.from(event.dependencies ?? [], (a) => a.toString()); subLogsArray.push(['old dependencies', oldDependenciesArray]); subLogsObject.oldDependencies = oldDependenciesArray; - } - if (showNewDependencies) { - newDependencies ??= new Set(); - const newDependenciesArray = Array.from(newDependencies); subLogsArray.push(['new dependencies', newDependenciesArray]); subLogsObject.newDependencies = newDependenciesArray; } if (showDependencies) { - const dependenciesArray = Array.from(dependencies); + const dependenciesArray = Array.from(dependencies, (a) => a.toString()); subLogsArray.push(['dependencies', dependenciesArray]); subLogsObject.dependencies = dependenciesArray; } if (showDependents) { - subLogsArray.push(['dependents', dependents]); - subLogsObject.dependents = dependents; + const dependentsArray = Array.from(dependents, (a) => a.toString()); + subLogsArray.push(['dependents', dependentsArray]); + subLogsObject.dependents = dependentsArray; } }) @@ -332,13 +323,3 @@ export const EventLogPipeline = new LogPipeline() } } }); - -/** - * Check if the event states should be added to the event. - */ -export function shouldSetStateInEvent(event: AtomsLoggerEvent): boolean { - // If the atom is unmounted or destroyed, we don't need to log anything else. - return ( - event.type !== AtomsLoggerEventTypes.unmounted && event.type !== AtomsLoggerEventTypes.destroyed - ); -} diff --git a/src/formatters/console/index.ts b/src/formatters/console/index.ts new file mode 100644 index 0000000..4ba0ecf --- /dev/null +++ b/src/formatters/console/index.ts @@ -0,0 +1,2 @@ +export { consoleFormatter } from './console-formatter.js'; +export type { ConsoleFormatterOptions } from './types.js'; diff --git a/src/log-atom-event/log-event.ts b/src/formatters/console/log-event.ts similarity index 75% rename from src/log-atom-event/log-event.ts rename to src/formatters/console/log-event.ts index cba278b..9a384f7 100644 --- a/src/log-atom-event/log-event.ts +++ b/src/formatters/console/log-event.ts @@ -1,12 +1,21 @@ -import { type AtomsLoggerEvent, type AtomsLoggerState } from '../types/atoms-logger.js'; +import { type AtomEvent } from '../../vanilla/types/event.js'; import { EventLogPipeline } from './event-log-pipeline.js'; +import type { ConsoleFormatterState } from './types.js'; -export function logEvent(event: AtomsLoggerEvent, options: AtomsLoggerState): void { +export function logEvent( + event: AtomEvent, + options: ConsoleFormatterState, + mergedOldValues?: unknown[], +): void { const { collapseEvents, logger } = options; let { groupEvents } = options; - const { logs, subLogsArray, subLogsObject } = EventLogPipeline.execute({ event, options }); + const { logs, subLogsArray, subLogsObject } = EventLogPipeline.execute({ + event, + options, + mergedOldValues, + }); if (collapseEvents ? !logger.groupCollapsed : !logger.group) { groupEvents = false; diff --git a/src/log-atom-event/log-pipeline.ts b/src/formatters/console/log-pipeline.ts similarity index 100% rename from src/log-atom-event/log-pipeline.ts rename to src/formatters/console/log-pipeline.ts diff --git a/src/formatters/console/log-transaction.ts b/src/formatters/console/log-transaction.ts new file mode 100644 index 0000000..2a4df3f --- /dev/null +++ b/src/formatters/console/log-transaction.ts @@ -0,0 +1,85 @@ +import { AtomEventTypes } from '../../vanilla/types/event.js'; +import type { AtomEvent, AtomEventChanged } from '../../vanilla/types/event.js'; +import type { AtomTransaction } from '../../vanilla/types/transaction.js'; +import { logEvent } from './log-event.js'; +import { TransactionLogPipeline } from './transaction-log-pipeline.js'; +import type { ConsoleFormatterState } from './types.js'; + +interface MergedEvent { + event: AtomEvent; + oldValues?: unknown[]; +} + +/** + * Merge multiple "changed" events for the same atom within a transaction into a single entry. + */ +function buildMergedEvents(events: AtomEvent[]): MergedEvent[] { + const mergedByAtom = new Map(); + const result: MergedEvent[] = []; + + for (const event of events) { + if (event.type === AtomEventTypes.changed) { + const existing = mergedByAtom.get(event.atom); + if (existing !== undefined) { + if (existing.oldValues !== undefined) { + existing.oldValues.push(event.oldValue); + } else { + existing.oldValues = [(existing.event as AtomEventChanged).oldValue, event.oldValue]; + } + existing.event = event; + continue; + } + const merged: MergedEvent = { event }; + mergedByAtom.set(event.atom, merged); + result.push(merged); + continue; + } + result.push({ event }); + } + + return result; +} + +export function logTransaction(transaction: AtomTransaction, options: ConsoleFormatterState): void { + const { logger, collapseTransactions } = options; + + let { groupTransactions } = options; + + const mergedEvents = buildMergedEvents(transaction.events); + + const { logs, additionalDataToLog } = TransactionLogPipeline.execute({ + transaction, + eventsCount: mergedEvents.length, + options, + }); + + if (Object.keys(additionalDataToLog).length > 0) { + logs.push(additionalDataToLog); + } + + if (collapseTransactions ? !logger.groupCollapsed : !logger.group) { + groupTransactions = false; + } else if (!logger.groupEnd) { + groupTransactions = false; + } + + try { + /* v8 ignore next -- empty logs only when all transaction header fields are disabled (showTransactionNumber, domain, eventsCount, time all off) with an unknown transaction type -- @preserve */ + if (logs.length > 0) { + if (!groupTransactions) { + logger.log(...logs); + } else if (collapseTransactions) { + logger.groupCollapsed?.(...logs); + } else { + logger.group?.(...logs); + } + } + for (const { event, oldValues } of mergedEvents) { + logEvent(event, options, oldValues); + } + } finally { + if (logs.length > 0 && groupTransactions) { + logger.groupEnd?.(); + } + } +} diff --git a/src/log-atom-event/transaction-log-pipeline.ts b/src/formatters/console/transaction-log-pipeline.ts similarity index 91% rename from src/log-atom-event/transaction-log-pipeline.ts rename to src/formatters/console/transaction-log-pipeline.ts index dd86028..3895e17 100644 --- a/src/log-atom-event/transaction-log-pipeline.ts +++ b/src/formatters/console/transaction-log-pipeline.ts @@ -1,30 +1,29 @@ -import { INTERNAL_isActuallyWritableAtom } from 'jotai/vanilla/internals'; +import { INTERNAL_isActuallyWritableAtom as isActuallyWritableAtom } from 'jotai/vanilla/internals'; +import type { AnyAtom, AtomId } from '../../vanilla/types/event.js'; import { - AtomsLoggerTransactionTypes, - type AnyAtom, - type AtomId, - type AtomsLoggerState, - type AtomsLoggerTransaction, - type AtomsLoggerTransactionType, -} from '../types/atoms-logger.js'; -import { hasAtomCustomWriteMethod } from '../utils/has-atom-custom-write-method.js'; -import { parseOwnerStack } from '../utils/parse-owner-stack.js'; -import { stringifyValue } from '../utils/stringify-value.js'; + AtomTransactionTypes, + type AtomTransaction, + type AtomTransactionType, +} from '../../vanilla/types/transaction.js'; import { addAtomToLogs } from './add-atom-to-logs.js'; import { addDashToLogs } from './add-dash-to-logs.js'; import { addToLogs } from './add-to-logs.js'; import { LogPipeline } from './log-pipeline.js'; +import type { ConsoleFormatterState } from './types.js'; +import { hasAtomCustomWriteMethod } from './utils/has-atom-custom-write-method.js'; +import { parseOwnerStack } from './utils/parse-owner-stack.js'; +import { stringifyValue } from './utils/stringify-value.js'; const addTransactionTypeToLogsMapping: Record< - Exclude, + Exclude, ({ hasCustomWriteMethod, }: { hasCustomWriteMethod: boolean | undefined; }) => Parameters[2] > = { - [AtomsLoggerTransactionTypes.storeSet]: ({ hasCustomWriteMethod }) => { + [AtomTransactionTypes.storeSet]: ({ hasCustomWriteMethod }) => { if (hasCustomWriteMethod) { return { plainText: () => 'called set of', @@ -37,23 +36,23 @@ const addTransactionTypeToLogsMapping: Record< }; } }, - [AtomsLoggerTransactionTypes.storeSubscribe]: () => ({ + [AtomTransactionTypes.storeSubscribe]: () => ({ plainText: () => 'subscribed to', formatted: () => ['%csubscribed %cto', ['green', 'bold'], 'grey'], }), - [AtomsLoggerTransactionTypes.storeUnsubscribe]: () => ({ + [AtomTransactionTypes.storeUnsubscribe]: () => ({ plainText: () => 'unsubscribed from', formatted: () => ['%cunsubscribed %cfrom', ['red', 'bold'], 'grey'], }), - [AtomsLoggerTransactionTypes.storeGet]: () => ({ + [AtomTransactionTypes.storeGet]: () => ({ plainText: () => 'retrieved value of', formatted: () => ['%cretrieved value %cof', ['blue', 'bold'], 'grey'], }), - [AtomsLoggerTransactionTypes.promiseResolved]: () => ({ + [AtomTransactionTypes.promiseResolved]: () => ({ plainText: () => 'resolved promise of', formatted: () => ['%cresolved %cpromise %cof', ['green', 'bold'], ['pink', 'bold'], 'grey'], }), - [AtomsLoggerTransactionTypes.promiseRejected]: () => ({ + [AtomTransactionTypes.promiseRejected]: () => ({ plainText: () => 'rejected promise of', formatted: () => ['%crejected %cpromise %cof', ['red', 'bold'], ['pink', 'bold'], 'grey'], }), @@ -61,8 +60,9 @@ const addTransactionTypeToLogsMapping: Record< export const TransactionLogPipeline = new LogPipeline() .withArgs<{ - transaction: AtomsLoggerTransaction; - options: AtomsLoggerState; + transaction: AtomTransaction; + eventsCount: number; + options: ConsoleFormatterState; }>() .withMeta<{ @@ -174,7 +174,7 @@ export const TransactionLogPipeline = new LogPipeline() logs, options, options: { autoAlignTransactions, maxWidths }, - transaction: { eventsCount }, + eventsCount, } = context; const numberContent = eventsCount.toString(); @@ -347,15 +347,12 @@ export const TransactionLogPipeline = new LogPipeline() } >(function addAtomToTransactionMeta(context) { const { transaction } = context; - if ( - transaction.type !== AtomsLoggerTransactionTypes.unknown && - transaction.atom !== undefined - ) { + if (transaction.type !== AtomTransactionTypes.unknown && transaction.atom !== undefined) { const atom = transaction.atom; context.showAtom = true; context.atom = atom; - const isWriteMethod = typeof atom !== 'string' && INTERNAL_isActuallyWritableAtom(atom); + const isWriteMethod = typeof atom !== 'string' && isActuallyWritableAtom(atom); context.hasCustomWriteMethod = isWriteMethod && hasAtomCustomWriteMethod(atom); context.hasDefaultWriteMethod = isWriteMethod && !hasAtomCustomWriteMethod(atom); } @@ -363,7 +360,7 @@ export const TransactionLogPipeline = new LogPipeline() .withMeta<{ showTransactionName?: true }>(function addTransactionNameToTransactionMeta(context) { const { showAtom, transaction } = context; - if (showAtom === true && transaction.type !== AtomsLoggerTransactionTypes.unknown) { + if (showAtom === true && transaction.type !== AtomTransactionTypes.unknown) { context.showTransactionName = true; } }) @@ -459,10 +456,7 @@ export const TransactionLogPipeline = new LogPipeline() logs, options, addTransactionTypeToLogsMapping[ - transaction.type as Exclude< - AtomsLoggerTransactionType, - AtomsLoggerTransactionTypes['unknown'] - > + transaction.type as Exclude ]({ hasCustomWriteMethod }), ); }) diff --git a/src/formatters/console/types.ts b/src/formatters/console/types.ts new file mode 100644 index 0000000..efe5f43 --- /dev/null +++ b/src/formatters/console/types.ts @@ -0,0 +1,162 @@ +/** + * Options for the console formatter. + */ +export interface ConsoleFormatterOptions { + /** + * Domain to use for the logger. + * + * The domain is used to identify the logger in the console. + * It is prefixed to the transaction number. + * + * - If not provided, the transaction log will look like : `transaction 1 - 12:00:00 - 2.00ms` + * - If provided, the transaction log will look like : `domain - transaction 1 - 12:00:00 - 2.00ms` + */ + domain?: string; + + /** + * Custom logger to use. + * + * By default, it uses the `console` global object. + * + * @default console + */ + logger?: Pick & Partial>; + + /** + * Whether to group transaction logs. + * + * @default true + */ + groupTransactions?: boolean; + + /** + * Whether to group event logs. + * + * @default false + */ + groupEvents?: boolean; + + /** + * Number of spaces for each level of indentation. + * + * @default 0 + */ + indentSpaces?: number; + + /** + * Whether to use colors/formatting in the console. + * + * @default true + */ + formattedOutput?: boolean; + + /** + * Color scheme to use for the logger. + * + * @default "default" + */ + colorScheme?: 'default' | 'light' | 'dark'; + + /** + * Maximum length of any logged stringified data. Use 0 for no limit. + * + * @default 50 + */ + stringifyLimit?: number; + + /** + * Whether to stringify data in the logs. + * + * @default true + */ + stringifyValues?: boolean; + + /** + * Custom function to stringify data in the logs. + */ + stringify?(this: void, value: unknown): string; + + /** + * Whether to show the transaction number. + * + * @default true + */ + showTransactionNumber?: boolean; + + /** + * Whether to show the number of events in a transaction. + * + * @default true + */ + showTransactionEventsCount?: boolean; + + /** + * Whether to show when a transaction started. + * + * @default false + */ + showTransactionLocaleTime?: boolean; + + /** + * Whether to show the elapsed time of a transaction. + * + * @default true + */ + showTransactionElapsedTime?: boolean; + + /** + * Automatically align transaction logs by padding fields to consistent widths. + * + * @default true + */ + autoAlignTransactions?: boolean; + + /** + * Whether to collapse grouped transaction logs by default. + * + * @default false + */ + collapseTransactions?: boolean; + + /** + * Whether to collapse grouped event logs by default. + * + * @default false + */ + collapseEvents?: boolean; + + /** + * Limit the number of components shown in the owner stack. + * + * @default 2 + */ + ownerStackLimit?: number; +} + +/** + * Internal resolved state for the console formatter, including derived and mutable fields. + */ +export interface ConsoleFormatterState { + domain: string | undefined; + logger: Pick & Partial>; + groupTransactions: boolean; + groupEvents: boolean; + indentSpaces: number; + indentSpacesDepth1: string; + indentSpacesDepth2: string; + formattedOutput: boolean; + colorScheme: 'default' | 'light' | 'dark'; + stringifyLimit: number; + stringifyValues: boolean; + stringify: ((this: void, value: unknown) => string) | undefined; + showTransactionNumber: boolean; + showTransactionEventsCount: boolean; + showTransactionLocaleTime: boolean; + showTransactionElapsedTime: boolean; + autoAlignTransactions: boolean; + collapseTransactions: boolean; + collapseEvents: boolean; + ownerStackLimit: number; + /** Mutable state for auto-alignment tracking across transactions */ + maxWidths: { eventsCount: number; elapsedTime: number }; +} diff --git a/src/utils/has-atom-custom-write-method.ts b/src/formatters/console/utils/has-atom-custom-write-method.ts similarity index 59% rename from src/utils/has-atom-custom-write-method.ts rename to src/formatters/console/utils/has-atom-custom-write-method.ts index cb11dcc..4549984 100644 --- a/src/utils/has-atom-custom-write-method.ts +++ b/src/formatters/console/utils/has-atom-custom-write-method.ts @@ -1,7 +1,7 @@ import { type WritableAtom, atom } from 'jotai'; -import { INTERNAL_isActuallyWritableAtom } from 'jotai/vanilla/internals'; +import { INTERNAL_isActuallyWritableAtom as isActuallyWritableAtom } from 'jotai/vanilla/internals'; -import type { AnyAtom } from '../types/atoms-logger.js'; +import type { AnyAtom } from '../../../vanilla/types/event.js'; const noopAtom = atom(); noopAtom.debugPrivate = true; @@ -11,5 +11,5 @@ const defaultAtomWrite = noopAtom.write as WritableAtom 0) { - logs.push(additionalDataToLog); - } - - if (collapseTransactions ? !logger.groupCollapsed : !logger.group) { - groupTransactions = false; - } else if (!logger.groupEnd) { - groupTransactions = false; - } - - try { - /* v8 ignore next -- empty logs only when all transaction header fields are disabled (showTransactionNumber, domain, eventsCount, time all off) with an unknown transaction type -- @preserve */ - if (logs.length > 0) { - if (!groupTransactions) { - logger.log(...logs); - } else if (collapseTransactions) { - logger.groupCollapsed?.(...logs); - } else { - logger.group?.(...logs); - } - } - for (const event of events) { - if (event) { - logEvent(event, options); - } - } - } finally { - if (logs.length > 0 && groupTransactions) { - logger.groupEnd?.(); - } - } -} diff --git a/src/log-transactions-scheduler.ts b/src/log-transactions-scheduler.ts deleted file mode 100644 index 609fdcf..0000000 --- a/src/log-transactions-scheduler.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from './consts/atom-logger-symbol.js'; -import { logTransaction } from './log-atom-event/log-transaction.js'; -import type { AtomsLoggerState, StoreWithAtomsLogger } from './types/atoms-logger.js'; - -// Check the time every N processed transactions to avoid doing it too often. -const checkTimeInterval = 10; - -export function createLogTransactionsScheduler( - store: StoreWithAtomsLogger, -): AtomsLoggerState['logTransactionsScheduler'] { - const logTransactionsScheduler: AtomsLoggerState['logTransactionsScheduler'] = { - queue: [], - isProcessing: false, - process(this: AtomsLoggerState['logTransactionsScheduler']) { - if (this.isProcessing || this.queue.length === 0) return; - const maxProcessingTimeMs = store[ATOMS_LOGGER_SYMBOL].maxProcessingTimeMs; - this.isProcessing = true; - schedule(() => { - try { - const startTime = maxProcessingTimeMs > 0 ? performance.now() : -1; // Not used if maxProcessingTimeMs <= 0 - - let processedCount = 0; - while (this.queue.length > 0) { - const transaction = this.queue.shift(); - if (transaction) { - logTransaction(transaction, store[ATOMS_LOGGER_SYMBOL]); - processedCount += 1; - - // Stop processing if we reached the max processing time - if ( - maxProcessingTimeMs > 0 && - processedCount % checkTimeInterval === 0 && - performance.now() - startTime >= maxProcessingTimeMs - ) { - break; - } - } - } - } finally { - this.isProcessing = false; - - // Continue processing if there are still transactions in the queue - if (this.queue.length > 0) { - this.process(); - } - } - }, store[ATOMS_LOGGER_SYMBOL]); - }, - add(transaction) { - this.queue.push(transaction); - this.process(); - }, - }; - return logTransactionsScheduler; -} - -function schedule( - cb: () => void, - { requestIdleCallbackTimeoutMs }: { requestIdleCallbackTimeoutMs: number }, -): void { - if (requestIdleCallbackTimeoutMs <= -1) { - cb(); - } else if (typeof globalThis.requestIdleCallback === 'function') { - globalThis.requestIdleCallback(cb, { timeout: requestIdleCallbackTimeoutMs }); - } else { - setTimeout(cb, 0); - } -} diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000..7a164b4 --- /dev/null +++ b/src/react.ts @@ -0,0 +1,3 @@ +export { AtomLoggerProvider } from './react/atom-logger-provider.js'; +export { createLoggedStore, isLoggedStore } from './vanilla/create-logged-store.js'; +export type { AtomLoggerOptions } from './vanilla/types/options.js'; diff --git a/src/react/atom-logger-provider.tsx b/src/react/atom-logger-provider.tsx new file mode 100644 index 0000000..a2f0ffd --- /dev/null +++ b/src/react/atom-logger-provider.tsx @@ -0,0 +1,45 @@ +import { Provider, useStore } from 'jotai'; +import type { Store } from 'jotai/vanilla/store'; +import { useMemo, useRef, type PropsWithChildren, type ReactNode } from 'react'; + +import { createLoggedStore } from '../vanilla/create-logged-store.js'; +import type { AtomLoggerOptions } from '../vanilla/types/options.js'; + +/** + * Provider that wraps a Jotai store with atom logging. + * + * It retrieves the nearest Jotai store from context, creates a new logged + * store derived from it, and propagates the logged store to all children via + * a Jotai ``. All `store.get`, `store.set` and `store.sub` calls + * made by children are intercepted and logged. + * + * @example + * ```tsx + * function App() { + * return ( + * + * + * + * + * + * ); + * } + * ``` + */ +export function AtomLoggerProvider({ + store, + children, + ...options +}: PropsWithChildren<{ store?: Store }> & AtomLoggerOptions): ReactNode { + const parentStore = useStore({ store }); + + const optionsRef = useRef(undefined); + if (optionsRef.current === undefined) optionsRef.current = { ...options }; + else Object.assign(optionsRef.current, options); + + const loggedStore = useMemo(() => { + return createLoggedStore(parentStore, optionsRef.current); + }, [parentStore]); + + return {children}; +} diff --git a/src/transactions/add-event-to-transaction.ts b/src/transactions/add-event-to-transaction.ts deleted file mode 100644 index f01a955..0000000 --- a/src/transactions/add-event-to-transaction.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { shouldSetStateInEvent } from '../log-atom-event/event-log-pipeline.js'; -import { - AtomsLoggerEventTypes, - AtomsLoggerTransactionTypes, - type AtomId, - type AtomsLoggerEvent, - type AtomsLoggerEventMap, - type AtomsLoggerTransaction, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; -import { convertAtomsToStrings } from '../utils/convert-atoms-to-strings.js'; -import { shouldShowAtom } from '../utils/should-show-atom.js'; -import { debounceEndTransaction } from './debounce-end-transaction.js'; -import { endTransaction } from './end-transaction.js'; -import { startTransaction } from './start-transaction.js'; - -export function addEventToTransaction(store: StoreWithAtomsLogger, event: AtomsLoggerEvent): void { - if (!shouldShowAtom(store, event.atom)) { - return; - } - - if (updateDependencies(store, event).shouldNotAddEvent) { - return; - } - - setStateInEvent(store, event); - - const transaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction; - - if (!transaction) { - // Execute the event in an independent "unknown" transaction if there is no current transaction. - startTransaction(store, { type: AtomsLoggerTransactionTypes.unknown, atom: event.atom }); - addEventToTransaction(store, event); - endTransaction(store); - return; - } - - // Debounce the transaction since a new event is added to it. - if (store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId !== undefined) { - debounceEndTransaction(store); - } - - // Add the event to the current transaction. - transaction.events.push(event); - transaction.eventsCount += 1; - - // Compute/reorder the events in the transaction. - mergeChangedEvents(transaction, event); - reversePromiseAbortedAndPending(transaction, event); -} - -/** - * Update the dependencies map if the event is a dependency change. - */ -function updateDependencies( - store: StoreWithAtomsLogger, - event: AtomsLoggerEvent, -): { shouldNotAddEvent: boolean } { - if (event.type === AtomsLoggerEventTypes.dependenciesChanged) { - const atom = event.atom; - - // Don't update dependencies if the added dependency is ignored - if (event.addedDependency && !shouldShowAtom(store, event.addedDependency)) { - return { shouldNotAddEvent: true }; - } - - // Don't update dependencies if the removed dependency is ignored - if (event.removedDependency && !shouldShowAtom(store, event.removedDependency)) { - return { shouldNotAddEvent: true }; - } - - let newDependencies: Set; - /* v8 ignore next 3 -- clearedDependencies path relies on d.clear(), which jotai 2.18+ no longer calls; kept for jotai 2.17.x compatibility -- @preserve */ - if (event.clearedDependencies) { - newDependencies = new Set(); - } else if (event.removedDependency) { - const currentDependencies = store[ATOMS_LOGGER_SYMBOL].dependenciesMap.get(atom); - newDependencies = new Set(currentDependencies); - newDependencies.delete(event.removedDependency.toString()); - } else { - const currentDependencies = store[ATOMS_LOGGER_SYMBOL].dependenciesMap.get(atom); - newDependencies = new Set(currentDependencies).add(event.addedDependency.toString()); - } - - store[ATOMS_LOGGER_SYMBOL].dependenciesMap.set(atom, newDependencies); - - // In jotai 2.18+, d.delete() fires AFTER the value is set (pruneDependencies runs - // after setAtomStateValueOrPromise). To preserve the correct event ordering - // (dependenciesChanged before changed value) and avoid double-events, we retroactively - // update existing dependenciesChanged events for this atom rather than appending a new one. - if (event.removedDependency) { - const currentTransaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction; - let hasExistingDepsChangedEvent = false; - /* v8 ignore next 3 -- d.delete() always fires inside a store operation (inside a transaction) in jotai 2.18+ -- @preserve */ - if (currentTransaction) { - for (const existingEvent of currentTransaction.events) { - if (!existingEvent) continue; - if (existingEvent.atom !== atom) continue; - if (existingEvent.type === AtomsLoggerEventTypes.dependenciesChanged) { - hasExistingDepsChangedEvent = true; - existingEvent.dependencies = newDependencies; - /* v8 ignore next 3 -- a non-dependenciesChanged event with .dependencies requires a same-transaction value+dep change that is extremely rare to reproduce -- @preserve */ - } else if (existingEvent.dependencies !== undefined) { - // Also update value change events so they reflect the final dep set - existingEvent.dependencies = newDependencies; - } - } - } - if (hasExistingDepsChangedEvent) { - // Existing dependenciesChanged events already updated — no need to add a new event - return { shouldNotAddEvent: true }; - } - // No prior dependenciesChanged events exist (pure-deletion case): fall through and add - // this event so cleanupDependencyChangedEvents can detect the change at flush time - } - } - return { shouldNotAddEvent: false }; -} - -/** - * Set the state of the atom in the event. - */ -function setStateInEvent(store: StoreWithAtomsLogger, event: AtomsLoggerEvent): void { - if (typeof event.atom === 'string' || !shouldSetStateInEvent(event)) return; - - event.dependencies = store[ATOMS_LOGGER_SYMBOL].dependenciesMap.get(event.atom); - - const options = store[ATOMS_LOGGER_SYMBOL]; - - const mountedState = store[ATOMS_LOGGER_SYMBOL].getMounted(event.atom); - event.dependents = convertAtomsToStrings(mountedState?.t.values(), options); - - const atomState = store[ATOMS_LOGGER_SYMBOL].getState(event.atom); - event.pendingPromises = convertAtomsToStrings(atomState?.p.values(), options); -} - -/** - * HACK: logs that a promise was aborted before a new one is pending - * - * In Jotai's code (`setAtomStateValueOrPromise`) the value of the promise is set **before** the abort event is triggered. - * This means that the abort event is added in the transaction after the new pending promise event. - * This hack just swap their order to make the log more readable. - */ -function reversePromiseAbortedAndPending( - transaction: AtomsLoggerTransaction, - event: AtomsLoggerEvent, -): void { - if ( - event.type === AtomsLoggerEventTypes.initialPromiseAborted || - event.type === AtomsLoggerEventTypes.changedPromiseAborted - ) { - const events = transaction.events; - /* v8 ignore next -- abort-as-only-event: the abort fires alone without a prior pending in the same transaction, which cannot happen in normal jotai usage -- @preserve */ - if (events.length > 1) { - const eventBeforeAbort = events[events.length - 2]; - if ( - eventBeforeAbort?.type === AtomsLoggerEventTypes.initialPromisePending || - eventBeforeAbort?.type === AtomsLoggerEventTypes.changedPromisePending - ) { - events[events.length - 2] = event; - events[events.length - 1] = eventBeforeAbort; - } - } - } -} - -/** - * Merge multiple "changed" events that occurs in the same transaction to prevent spam. - */ -function mergeChangedEvents(transaction: AtomsLoggerTransaction, event: AtomsLoggerEvent): void { - if (event.type === AtomsLoggerEventTypes.changed) { - const previousChangedEventIndex = transaction.events.findIndex((previousEvent) => { - return ( - previousEvent !== undefined && - previousEvent !== event && - previousEvent.type === AtomsLoggerEventTypes.changed && - previousEvent.atom === event.atom - ); - }); - if (previousChangedEventIndex > -1) { - const previousChangedEvent = transaction.events[ - previousChangedEventIndex - ] as AtomsLoggerEventMap[AtomsLoggerEventTypes['changed']]; - let oldValues: unknown[]; - if (previousChangedEvent.oldValues !== undefined) { - oldValues = previousChangedEvent.oldValues; - oldValues.push(event.oldValue); - } else { - oldValues = [previousChangedEvent.oldValue, event.oldValue]; - } - event.oldValues = oldValues; - delete event.oldValue; - transaction.events[previousChangedEventIndex] = undefined; - transaction.eventsCount -= 1; - } - } -} diff --git a/src/transactions/debounce-end-transaction.ts b/src/transactions/debounce-end-transaction.ts deleted file mode 100644 index 0d7726b..0000000 --- a/src/transactions/debounce-end-transaction.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { StoreWithAtomsLogger } from '../types/atoms-logger.js'; -import { flushTransactionEvents } from './flush-transaction-events.js'; -import { stopEndTransactionDebounce } from './stop-end-transaction-debounce.js'; -import { updateTransactionEndTimestamp } from './update-transaction-end-timestamp.js'; - -export function debounceEndTransaction(store: StoreWithAtomsLogger) { - stopEndTransactionDebounce(store); - - // Store the transaction end timestamp BEFORE debouncing - updateTransactionEndTimestamp(store); - - store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId = setTimeout(() => { - store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId = undefined; - flushTransactionEvents(store); - }, store[ATOMS_LOGGER_SYMBOL].transactionDebounceMs); -} diff --git a/src/transactions/flush-transaction-events.ts b/src/transactions/flush-transaction-events.ts deleted file mode 100644 index 6ea9eab..0000000 --- a/src/transactions/flush-transaction-events.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import { - AtomsLoggerEventTypes, - type AnyAtom, - type AtomId, - type AtomsLoggerTransaction, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; - -export function flushTransactionEvents(store: StoreWithAtomsLogger): void { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should never happen since it is called in endTransaction - const transaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction!; - - store[ATOMS_LOGGER_SYMBOL].currentTransaction = undefined; - - // Cleanup the dependencies events that have not changed since the last transaction. - cleanupDependencyChangedEvents(store, transaction); - - // If the transaction has no events, we don't need to log it. - if (transaction.eventsCount <= 0) { - return; - } - - // Only increment the transaction number if the current transaction is logged - store[ATOMS_LOGGER_SYMBOL].transactionNumber += 1; - - // Add current transaction to scheduler instead of executing immediately - store[ATOMS_LOGGER_SYMBOL].logTransactionsScheduler.add(transaction); -} - -/** - * Cleanup the dependencies events that have not changed since the last - * transaction or that are duplicated. - */ -function cleanupDependencyChangedEvents( - store: StoreWithAtomsLogger, - transaction: AtomsLoggerTransaction, -): void { - const existingDependencyChangedEventsMap = new WeakSet(); - - for (let eventIndex = transaction.events.length - 1; eventIndex >= 0; eventIndex -= 1) { - const event = transaction.events[eventIndex]; - if (!event) continue; - if (event.type !== AtomsLoggerEventTypes.dependenciesChanged) continue; - - const atom = event.atom; - - // Remove the event if it is a duplicate - if (existingDependencyChangedEventsMap.has(atom)) { - transaction.events[eventIndex] = undefined; - transaction.eventsCount -= 1; - continue; - } - existingDependencyChangedEventsMap.add(atom); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should always be set in this event - const newDependencies = event.dependencies!; - - event.oldDependencies = store[ATOMS_LOGGER_SYMBOL].prevTransactionDependenciesMap.get(atom); - - store[ATOMS_LOGGER_SYMBOL].prevTransactionDependenciesMap.set(atom, newDependencies); - - // Don't log initial dependencies or dependencies that are not changed - if ( - event.oldDependencies === undefined || - !hasDependenciesChanged(event.oldDependencies, newDependencies) - ) { - transaction.events[eventIndex] = undefined; - transaction.eventsCount -= 1; - continue; - } - } - - // In jotai 2.17.x, d.clear() was called on every atom read, which updated - // prevTransactionDependenciesMap even for atoms with no visible deps. - // In jotai 2.18+, d.clear() is gone and d.delete() only fires for removed deps. - // Atoms that are initialized but have only private deps produce no dependenciesChanged - // events, so prevTransactionDependenciesMap is never initialized for them. - // We initialize it here to Set([]) so future dep additions can be correctly detected. - for (const event of transaction.events) { - if (!event) continue; - if (event.type !== AtomsLoggerEventTypes.initialized) continue; - const atom = event.atom; - if (!store[ATOMS_LOGGER_SYMBOL].prevTransactionDependenciesMap.has(atom)) { - store[ATOMS_LOGGER_SYMBOL].prevTransactionDependenciesMap.set( - atom, - new Set(store[ATOMS_LOGGER_SYMBOL].dependenciesMap.get(atom)), - ); - } - } -} - -/** - * Checks if the dependencies have changed. - */ -function hasDependenciesChanged( - oldDependencies: Set, - newDependencies: Set, -): boolean { - if (oldDependencies.size !== newDependencies.size) { - return true; - } - - for (const dep of oldDependencies) { - if (!newDependencies.has(dep)) { - return true; - } - } - - return false; -} diff --git a/src/transactions/start-transaction.ts b/src/transactions/start-transaction.ts deleted file mode 100644 index 56fc72d..0000000 --- a/src/transactions/start-transaction.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { - AtomsLoggerTransaction, - AtomsLoggerTransactionMap, - StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; -import { shouldShowAtom } from '../utils/should-show-atom.js'; -import { endTransaction } from './end-transaction.js'; - -export function startTransaction( - store: StoreWithAtomsLogger, - partialTransaction: { - [K in keyof AtomsLoggerTransactionMap]: Omit< - AtomsLoggerTransactionMap[K], - | 'transactionNumber' - | 'events' - | 'eventsCount' - | 'startTimestamp' - | 'endTimestamp' - | 'ownerStack' - | 'componentDisplayName' - >; - }[keyof AtomsLoggerTransactionMap], -): void { - if (store[ATOMS_LOGGER_SYMBOL].currentTransaction) { - // Finish the previous transaction immediately to start a new one. - endTransaction(store, { immediate: true }); - } - - store[ATOMS_LOGGER_SYMBOL].isInsideTransaction = true; - - const transaction = partialTransaction as AtomsLoggerTransaction; - - transaction.transactionNumber = store[ATOMS_LOGGER_SYMBOL].transactionNumber; - transaction.events = []; - transaction.eventsCount = 0; - - if ( - store[ATOMS_LOGGER_SYMBOL].showTransactionElapsedTime || - store[ATOMS_LOGGER_SYMBOL].showTransactionLocaleTime - ) { - transaction.startTimestamp = performance.now(); - } - - if (!transaction.componentDisplayName && store[ATOMS_LOGGER_SYMBOL].getComponentDisplayName) { - try { - // Try to get the component display name. - // Do it at the start AND the end of the transaction to cover more cases since this can fail. - transaction.componentDisplayName = store[ATOMS_LOGGER_SYMBOL].getComponentDisplayName(); - } catch { - transaction.componentDisplayName = undefined; - } - } - - if (transaction.atom && !shouldShowAtom(store, transaction.atom)) { - transaction.atom = undefined; - } - - store[ATOMS_LOGGER_SYMBOL].currentTransaction = transaction; -} diff --git a/src/transactions/stop-end-transaction-debounce.ts b/src/transactions/stop-end-transaction-debounce.ts deleted file mode 100644 index debb2f5..0000000 --- a/src/transactions/stop-end-transaction-debounce.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function stopEndTransactionDebounce(store: StoreWithAtomsLogger) { - if (store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId !== undefined) { - clearTimeout(store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId); - store[ATOMS_LOGGER_SYMBOL].transactionsDebounceTimeoutId = undefined; - } -} diff --git a/src/transactions/update-transaction-end-timestamp.ts b/src/transactions/update-transaction-end-timestamp.ts deleted file mode 100644 index 21ea68b..0000000 --- a/src/transactions/update-transaction-end-timestamp.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function updateTransactionEndTimestamp(store: StoreWithAtomsLogger): void { - if ( - !store[ATOMS_LOGGER_SYMBOL].showTransactionElapsedTime && - !store[ATOMS_LOGGER_SYMBOL].showTransactionLocaleTime - ) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should never happen since it is called after startTransaction - const transaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction!; - transaction.endTimestamp = performance.now(); -} diff --git a/src/types/atoms-logger.ts b/src/types/atoms-logger.ts deleted file mode 100644 index 68effdd..0000000 --- a/src/types/atoms-logger.ts +++ /dev/null @@ -1,795 +0,0 @@ -import type { Atom, useStore } from 'jotai'; -import type { - INTERNAL_AtomState, - INTERNAL_AtomStateMap, - INTERNAL_getBuildingBlocksRev2, - INTERNAL_Mounted, -} from 'jotai/vanilla/internals'; - -import type { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; - -/** - * Jotai's store. - */ -export type Store = ReturnType; - -/** - * Type of the store with the logger attached. - */ -export type StoreWithAtomsLogger = Store & { - [ATOMS_LOGGER_SYMBOL]: AtomsLoggerState; -}; - -/** - * String representation of an atom. - */ -export type AtomId = ReturnType; - -/** - * Generic atom type. - */ -export type AnyAtom = Atom; - -/** - * Internal state of the logger. - * - * Contains configuration options, transaction tracking, and references to original store methods. - */ -export type AtomsLoggerState = AtomsLoggerOptionsInState & { - /** Internal method to register abort handlers for promises */ - registerAbortHandler: ReturnType[26]; - /** Incremental counter for transactions */ - transactionNumber: number; - /** The currently active transaction being tracked, if any */ - currentTransaction: AtomsLoggerTransaction | undefined; - /** Flag to indicate if the logger is currently processing a transaction (not debouncing) */ - isInsideTransaction: boolean; - /** FinalizationRegistry that register atoms garbage collection */ - atomsFinalizationRegistry: FinalizationRegistry; - /** Map to track the values of promises */ - promisesResultsMap: WeakMap, unknown>; - /** Map to track the previous dependencies of atoms since last transaction */ - prevTransactionDependenciesMap: WeakMap>; - /** Map to track the dependencies of atoms */ - dependenciesMap: WeakMap>; - /** Timeout id of the current transaction if started independently (not triggered by a store update) */ - transactionsDebounceTimeoutId: ReturnType | undefined; - /** Scheduler for logging queued transactions */ - logTransactionsScheduler: { - /** Queue of transactions to be logged */ - queue: AtomsLoggerTransaction[]; - /** Flag to indicate if the scheduler is currently processing */ - isProcessing: boolean; - /** Process the next transaction in the queue */ - process: () => void; - /** Add a transaction to the queue and process it */ - add: (transaction: AtomsLoggerTransaction) => void; - }; - /** Maximum widths tracked for auto-alignment when autoAlignTransactions is enabled */ - maxWidths: { - eventsCount: number; - elapsedTime: number; - }; - /** Previous overridden store.get method */ - prevStoreGet: StoreWithAtomsLogger['get']; - /** Previous overridden store.set method */ - prevStoreSet: StoreWithAtomsLogger['set']; - /** Previous overridden store.sub method */ - prevStoreSub: StoreWithAtomsLogger['sub']; - /** Previous overridden atom state map setter method */ - prevAtomStateMapSet: INTERNAL_AtomStateMap['set']; - /** Return the state of an atom */ - getState(this: void, atom: AnyAtom): INTERNAL_AtomState | undefined; - /** Return the mounted state of an atom */ - getMounted(this: void, atom: AnyAtom): INTERNAL_Mounted | undefined; -}; - -/** - * Logger options stored in the logger's state - * @see {@link AtomsLoggerOptions} for the public API. - */ -export interface AtomsLoggerOptionsInState { - /** @see AtomsLoggerOptions.enabled */ - enabled: boolean; - - /** @see AtomsLoggerOptions.domain */ - domain: string | undefined; - - /** @see AtomsLoggerOptions.shouldShowPrivateAtoms */ - shouldShowPrivateAtoms: boolean; - - /** @see AtomsLoggerOptions.shouldShowAtom */ - shouldShowAtom: ((atom: Atom) => boolean) | undefined; - - /** @see AtomsLoggerOptions.logger */ - logger: Pick & Partial>; - - /** @see AtomsLoggerOptions.groupTransactions */ - groupTransactions: boolean; - - /** @see AtomsLoggerOptions.groupEvents */ - groupEvents: boolean; - - /** @see AtomsLoggerOptions.indentSpaces */ - indentSpaces: number; - - /** @see AtomsLoggerOptions.indentSpaces */ - indentSpacesDepth1: string; - - /** @see AtomsLoggerOptions.indentSpaces */ - indentSpacesDepth2: string; - - /** @see AtomsLoggerOptions.formattedOutput */ - formattedOutput: boolean; - - /** @see AtomsLoggerOptions.colorScheme */ - colorScheme: 'default' | 'light' | 'dark'; - - /** @see AtomsLoggerOptions.stringifyLimit */ - stringifyLimit: number; - - /** @see AtomsLoggerOptions.stringifyValues */ - stringifyValues: boolean; - - /** @see AtomsLoggerOptions.stringify */ - stringify: ((this: void, value: unknown) => string) | undefined; - - /** @see AtomsLoggerOptions.showTransactionNumber */ - showTransactionNumber: boolean; - - /** @see AtomsLoggerOptions.showTransactionEventsCount */ - showTransactionEventsCount: boolean; - - /** @see AtomsLoggerOptions.showTransactionLocaleTime */ - showTransactionLocaleTime: boolean; - - /** @see AtomsLoggerOptions.showTransactionElapsedTime */ - showTransactionElapsedTime: boolean; - - /** @see AtomsLoggerOptions.autoAlignTransactions */ - autoAlignTransactions: boolean; - - /** @see AtomsLoggerOptions.collapseTransactions */ - collapseTransactions: boolean; - - /** @see AtomsLoggerOptions.collapseEvents */ - collapseEvents: boolean; - - /** @see AtomsLoggerOptions.ownerStackLimit */ - ownerStackLimit: number; - - /** @see AtomsLoggerOptions.getOwnerStack */ - getOwnerStack?(this: void): string | null | undefined; - - /** @see AtomsLoggerOptions.getComponentDisplayName */ - getComponentDisplayName?(this: void): string | undefined; - - /** @see AtomsLoggerOptions.transactionDebounceMs */ - transactionDebounceMs: number; - - /** @see AtomsLoggerOptions.requestIdleCallbackTimeoutMs */ - requestIdleCallbackTimeoutMs: number; - - /** @see AtomsLoggerOptions.maxProcessingTimeMs */ - maxProcessingTimeMs: number; -} - -/** - * Options for the atoms logger. - */ -export interface AtomsLoggerOptions { - /** - * Enable or disable the logger. - * - * @default true - */ - enabled?: boolean; - - /** - * Domain to use for the logger. - * - * The domain is used to identify the logger in the console. - * It is prefixed to the transaction number. - * - * - If not provided, the transaction log will look like : `transaction 1 - 12:00:00 - 2.00ms` - * - If provided, the transaction log will look like : `domain - transaction 1 - 12:00:00 - 2.00ms` - */ - domain?: string; - - /** - * Whether to show private atoms in the console. - * - * Private are atoms that are used by Jotai libraries internally to manage state. - * They're often used internally in atoms like `atomWithStorage` or `atomWithLocation`, etc. to manage state. - * They are determined by the `debugPrivate` property of the atom. - * - * @default false - */ - shouldShowPrivateAtoms?: boolean; - - /** - * Function to determine whether to show a specific atom in the console. - * - * This is useful for filtering out atoms that you don't want to see in the console. - * - * `shouldShowPrivateAtoms` takes precedence over this option. - * - * @example - * ```ts - * // Show all atoms that have a debug label - * const shouldShowAtom = (atom: Atom) => atom.debugLabel !== undefined; - * useAtomsLogger({ shouldShowAtom }); - * - * // Don't show a specific atom - * const verboseAtom = atom(0); - * const shouldShowAtom = (atom: Atom) => atom !== verboseAtom; - * useAtomsLogger({ shouldShowAtom }); - * - * // Dont show an atom with a specific property - * const verboseAtom = atom(0); - * verboseAtom.debugLabel = 'verbose'; - * Object.assign(verboseAtom, { canLog: false }); - * const shouldShowAtom = (atom: Atom) => !('canLog' in atom) || atom.canLog === true; - * useAtomsLogger({ shouldShowAtom }); - * ``` - */ - shouldShowAtom?(this: void, atom: Atom): boolean; - - /** - * Custom logger to use. - * - * By default, it uses the `console` global object. - * - * If either `groupTransactions` and `groupEvents` are `false` - * or `logger.group` and `logger.groupEnd` are not provided, logs will not be grouped. - * - * @default console - */ - logger?: Pick & Partial>; - - /** - * Whether to group transaction logs with `logger.group` and `logger.groupEnd`. - * - * - If set to `true`, transaction will be grouped using `logger.group`, `logger.groupCollapsed` and `logger.groupEnd`. - * - If set to `false`, only `logger.log` will be used. - * This can be useful if using a custom `logger` that doesn't support grouping or for testing purposes. - * - * @default true - */ - groupTransactions?: boolean; - - /** - * Whether to group event logs with `logger.group` and `logger.groupEnd`. - * - * - If set to `true`, event logs will be grouped using `logger.group`, `logger.groupCollapsed` and `logger.groupEnd`. - * - If set to `false`, only `logger.log` will be used. - * This can be useful if using a custom `logger` that doesn't support grouping or for testing purposes. - * - * @default false - */ - groupEvents?: boolean; - - /** - * Number of spaces to use for each level of indentation in the logs. - * - * Set to 0 to disable indentation completely. - * - * @default 0 - */ - indentSpaces?: number; - - /** - * Whether to use colors/formatting in the console. - * - * - If set to `true`, the logger will use formatted and colorized output using [the browser console's string substitutions](https://developer.mozilla.org/en-US/docs/Web/API/console#using_string_substitutions) (%c / %o). - * This works with the `colorScheme` option to determine the colors to use. - * - If set to `false`, the logger will use plain texts without formatting. - * - * This is useful for testing purposes or if you want to use the logger in a non-browser environment. - * - * @default true - */ - formattedOutput?: boolean; - - /** - * Color scheme to use for the logger. - * - * This is used to determine the colors of the logs in the console. - * The default color scheme uses colors that are easy to read in both light and dark mode. - * - * - If `default`, the logger will use the colors that are easy to read in both light and dark mode. If will **NOT** use the system preference. - * - If `light`, the logger will use the colors that are easy to read in light mode. - * - If `dark`, the logger will use the colors that are easy to read in dark mode. - * - * See example bellow if you want the colors to be automatically determined based on the user's system preference using `window.matchMedia`. - * - * This option has no effect if `formattedOutput` is set to `false`. - * - * @default "default" - * - * @example - * ```ts - * // If you want the colors to be automatically determined based on the user's system preference - * useAtomsLogger({ colorScheme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" }); - * - * // If you want the color to be specified in an environment variable (in vite) - * useAtomsLogger({ colorScheme: import.meta.env.VITE_ATOMS_LOGGER_COLOR_SCHEME }); - * ``` - */ - colorScheme?: 'default' | 'light' | 'dark'; - - /** - * Maximum length of any logged stringified data. - * - * This includes the state of atoms, the arguments and results of atoms setter methods, etc. - * - * If the string is longer, it will be truncated and appended with '…'. - * Use 0 for no limit. - * - * @default 50 - */ - stringifyLimit?: number; - - /** - * Whether to stringify data in the logs. - * - * This includes the state of atoms, the arguments and results of atoms - * setter methods, etc. - * - * - If set to `true`, the logged data will be stringified using `stringify` with a maximum length of `stringifyLimit`. - * - If set to `false`, the logged data will be logged as is. - * - * @default true - */ - stringifyValues?: boolean; - - /** - * Custom function to stringify data in the logs. - * - * This includes the state of atoms, the arguments and results of atoms - * setter methods, etc. - * - * If not provided, a basic stringification using `toString()` and `JSON.stringify` will be used. - * This makes the logger library agnostic to the stringification library used. - * - * `stringifyLimit` is still applied to the output of this function. - * - * @example - * ```ts - * // Example using Jest's / Vitest's pretty-format: - * import { format as prettyFormat } from '@vitest/pretty-format'; - * useAtomsLogger({ - * stringify(value) { - * return prettyFormat(value, { min: true, maxDepth: 3, maxWidth: 5 }); - * } - * }); - * ``` - */ - stringify?(this: void, value: unknown): string; - - /** - * Whether to show the transaction number in the console. - * - * - If set to `true`, the transaction log will look like : `transaction 1 - 12:00:00 - 2.00 ms` - * - If set to `false`, the transaction log will look like : `12:00:00 - 2.00 ms` - * - * @default true - */ - showTransactionNumber?: boolean; - - /** - * Whether to show the number of events in a transaction in the console. - * - * - If set to `true`, the transaction log will look like : `transaction 1 - 3 events : retrieved value of atom1` - * - If set to `false`, the transaction log will look like : `transaction 1 : retrieved value of atom1` - * - * @default true - */ - showTransactionEventsCount?: boolean; - - /** - * Whether to show when a transaction started in the console. - * - * - If set to `true`, the transaction log will look like : `transaction 1 - 12:00:00 - 2.00 ms` - * - If set to `false`, the transaction log will look like : `transaction 1 - 2.00 ms` - * - * @default false - */ - showTransactionLocaleTime?: boolean; - - /** - * Whether to show the elapsed time of a transaction in the console. - * - * - If set to `true`, the transaction log will look like : `transaction 1 - 12:00:00 - 2.00 ms` - * - If set to `false`, the transaction log will look like : `transaction 1 - 12:00:00` - * - * @default true - */ - showTransactionElapsedTime?: boolean; - - /** - * Automatically align transaction logs by padding fields to consistent widths. - * - * When enabled, the logger will track the maximum width of each transaction component - * (number, events count, timestamp, elapsed time) and automatically pad them for - * perfect column alignment, similar to a data table. - * - * Example output: - * ``` - * transaction 9 - 2 events - 14:39:27 - 1.10 ms : ... - * transaction 10 - 12 events - 14:39:27 - 301.10 ms : ... - * transaction 11 - 1 event - 14:39:27 - 1.10 ms : ... - * ``` - * - * @default true - */ - autoAlignTransactions?: boolean; - - /** - * Whether to collapse grouped transaction logs by default using `logger.groupCollapsed` instead of `logger.group`. - * - * Only applies if `groupTransactions` is `true`. - * - * This is useful for reducing clutter in the console. - * - * @default false - */ - collapseTransactions?: boolean; - - /** - * Whether to collapse grouped events logs by default using `logger.groupCollapsed` instead of `logger.group`. - * - * Only applies if `groupEvents` is `true`. - * - * This is useful for reducing clutter in the console. - * - * @default false - */ - collapseEvents?: boolean; - - /** - * **Experimental feature** - Limit the number of components shown in the owner stack. - * - * This option limits how many parent components are shown in the owner stack - * retrieved by `getOwnerStack`. This can be useful to reduce clutter in the - * logs. - * - * - If set to a positive number, it will show up to that many parent components - * in the logs. - * - If set to `0`, it will not show any parent components in the logs. - * - If set to a negative number or `Infinity`, it will show all parent components - * in the logs. - * - * @default 2 - */ - ownerStackLimit?: number; - - /** - * **Experimental feature** - Get the React component owner stack. - * - * This function should return a stack trace string showing the React component hierarchy - * that triggered the current transaction. The logger will parse this to show up to - * `ownerStackLimit` parent components in the logs. - * - * **React 19.1+ Example:** - * ```tsx - * import { captureOwnerStack } from 'react'; - * - * useAtomsLogger({ getOwnerStack: captureOwnerStack }); - * ``` - * - * **Expected format:** - * ``` - * at MiddleWrapper (http://localhost:5173/src/App.tsx:70:21) - * at ParentContainer (http://localhost:5173/src/App.tsx:31:21) - * at App (http://localhost:5173/src/App.tsx:108:21) - * ``` - * - * **Output example:** - * ``` - * transaction 1 : [ParentContainer.MiddleWrapper] retrieved value of countAtom - * ``` - * - * @returns Stack trace string, null, or undefined - */ - getOwnerStack?(this: void): string | null | undefined; - - /** - * **Experimental feature** - Get the current React component's display name. - * - * This function should return the display name or name of the currently rendering - * React component. The logger will show this in transaction logs to help identify - * which component triggered the state change. - * - * **React 19+ Example:** - * ```tsx - * import React from 'react'; - * - * function getReact19ComponentDisplayName(): string | undefined { - * const React19 = React as any; - * const component = ( - * React19.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? - * React19.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE - * )?.A?.getOwner?.().type; - * return component?.displayName ?? component?.name; - * } - * - * useAtomsLogger({ - * getComponentDisplayName: getReact19ComponentDisplayName - * }); - * ``` - * - * **Output example:** - * ``` - * transaction 1 : MyCounter retrieved value of countAtom - * ``` - * - * **Note:** When used with `getOwnerStack`, the component display name will only - * be shown if it's different from the last component shown in the owner stack. - * - * @returns Component display name or undefined - */ - getComponentDisplayName?(this: void): string | undefined; - - /** - * Whether to log transactions synchronously or asynchronously. - * - * - If set to `true`, the logger will log transactions synchronously - * - This makes `transactionDebounceMs`, `requestIdleCallbackTimeoutMs` - * and `maxProcessingTimeMs` options irrelevant. - * - This is useful for debugging purposes or if you want to see the logs - * immediately. - * - If set to `false`, the logger will log transactions asynchronously - * - First, transaction events are debounced using `transactionDebounceMs` - * option. - * - Then, the transactions are scheduled to be logged using - * `requestIdleCallback` with a maximum timeout defined by - * `requestIdleCallbackTimeoutMs` option. - * - Finally, the transactions are processed in chunks with a maximum - * processing time defined by `maxProcessingTimeMs` option. - * - This is useful for reducing the impact of the logger on the application - * performance. - * - * @default false - */ - synchronous?: boolean; - - /** - * Debounce time for transaction flushing in milliseconds. - * - * Only used if `synchronous` is set to `false`. - * - * - This ensures that multiple independent events are logged together in a - * single transaction instead of multiple transactions. - * - * - Use `0` for no debounce, which means that every transaction will be - * scheduled to be logged immediately. This is the same as setting - * `synchronous` to `true`. - * - * @default 250 - */ - transactionDebounceMs?: number; - - /** - * Timeout in milliseconds for the - * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#timeout | `requestIdleCallback`} - * used to flush transactions. - * - * Only used if `synchronous` is set to `false`. - * - * - `requestIdleCallback` queues transactions to be logged during a browser's - * idle periods with this maximum timeout per group of transactions. - * This ensure that the logger does not impact too much the application performances. - * - * - Use a positive value to set a maximum timeout for the - * `requestIdleCallback`. - * - This means that the logger will wait for the browser to be idle for at - * least this amount of time before logging the queued transactions. - * - It will fallback to `setTimeout` with a timeout of `0` if the browser - * does not support `requestIdleCallback`. - * - * - Use `0` to wait indefinitely for the browser to be idle before logging - * the queued transactions. - * - It will fallback to `setTimeout` with a timeout of `0` if the browser - * does not support `requestIdleCallback`. - * - * - Use `-1` or lower to log scheduled transactions immediately. This is the - * same as setting `synchronous` to `true`. - * - * @default 250 - */ - requestIdleCallbackTimeoutMs?: number; - - /** - * Maximum number of milliseconds to process transactions in one go. - * - * Only used if `synchronous` is set to `false`. - * - * - This is to avoid blocking the main thread for too long. - * - If there are still transactions in the queue after this time, they will be - * scheduled to be processed in the next idle period. - * - * - Use a positive value to set the maximum processing time per idle period. - * - Use `0` or lower to process all queued transactions in one go, which may block - * the main thread for a long time. - * This is the same as setting `synchronous` to `true`. - * - * @default 16 (approximately 1 frame at 60fps) - */ - maxProcessingTimeMs?: number; -} - -export const AtomsLoggerTransactionTypes = { - unknown: 1, - storeGet: 2, - storeSet: 3, - storeSubscribe: 4, - storeUnsubscribe: 5, - promiseResolved: 6, - promiseRejected: 7, -} as const; - -export type AtomsLoggerTransactionTypes = typeof AtomsLoggerTransactionTypes; - -export type AtomsLoggerTransactionType = - AtomsLoggerTransactionTypes[keyof AtomsLoggerTransactionTypes]; - -export type AtomsLoggerTransactionBase< - TData extends { - type: AtomsLoggerTransactionType; - }, -> = TData & { - atom: AnyAtom | AtomId | undefined; - transactionNumber: number; - ownerStack?: string | null | undefined; - componentDisplayName?: string | undefined; - events: (AtomsLoggerEvent | undefined)[]; - eventsCount: number; - startTimestamp: ReturnType; - endTimestamp: ReturnType; -}; - -export interface AtomsLoggerTransactionMap { - [AtomsLoggerTransactionTypes.unknown]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['unknown']; - }>; - [AtomsLoggerTransactionTypes.storeGet]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['storeGet']; - }>; - [AtomsLoggerTransactionTypes.storeSet]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['storeSet']; - args: unknown[]; - result: unknown; - }>; - [AtomsLoggerTransactionTypes.storeSubscribe]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['storeSubscribe']; - listener: () => void; - }>; - [AtomsLoggerTransactionTypes.storeUnsubscribe]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['storeUnsubscribe']; - listener: () => void; - }>; - [AtomsLoggerTransactionTypes.promiseResolved]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['promiseResolved']; - }>; - [AtomsLoggerTransactionTypes.promiseRejected]: AtomsLoggerTransactionBase<{ - type: AtomsLoggerTransactionTypes['promiseRejected']; - }>; -} - -export type AtomsLoggerTransaction = AtomsLoggerTransactionMap[keyof AtomsLoggerTransactionMap]; - -export const AtomsLoggerEventTypes = { - initialized: 1, - initialPromisePending: 2, - initialPromiseResolved: 3, - initialPromiseRejected: 4, - initialPromiseAborted: 5, - changed: 6, - changedPromisePending: 7, - changedPromiseResolved: 8, - changedPromiseRejected: 9, - changedPromiseAborted: 10, - dependenciesChanged: 11, - mounted: 12, - unmounted: 13, - destroyed: 14, -} as const; - -export type AtomsLoggerEventTypes = typeof AtomsLoggerEventTypes; -export type AtomsLoggerEventType = AtomsLoggerEventTypes[keyof AtomsLoggerEventTypes]; - -export type AtomsLoggerEventBase< - TData extends { type: AtomsLoggerEventType; atom: AnyAtom | AtomId } = { - type: AtomsLoggerEventType; - atom: AnyAtom | AtomId; - }, -> = TData & { - /** @see {@link INTERNAL_AtomState.p} */ - pendingPromises?: AtomId[]; - /** @see {@link INTERNAL_AtomState.d} @see {@link INTERNAL_Mounted.d} */ - dependencies?: Set; - /** @see {@link INTERNAL_Mounted.t} */ - dependents?: AtomId[]; -}; - -export interface AtomsLoggerEventMap { - [AtomsLoggerEventTypes.initialized]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['initialized']; - atom: AnyAtom; - value: unknown; - }>; - [AtomsLoggerEventTypes.initialPromisePending]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['initialPromisePending']; - atom: AnyAtom; - }>; - [AtomsLoggerEventTypes.initialPromiseResolved]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['initialPromiseResolved']; - atom: AnyAtom; - value: unknown; - }>; - [AtomsLoggerEventTypes.initialPromiseRejected]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['initialPromiseRejected']; - atom: AnyAtom; - error: unknown; - }>; - [AtomsLoggerEventTypes.initialPromiseAborted]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['initialPromiseAborted']; - atom: AnyAtom; - }>; - [AtomsLoggerEventTypes.changed]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['changed']; - atom: AnyAtom; - oldValue?: unknown; - oldValues?: unknown[]; - newValue: unknown; - }>; - [AtomsLoggerEventTypes.changedPromisePending]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['changedPromisePending']; - atom: AnyAtom; - oldValue: unknown; - }>; - [AtomsLoggerEventTypes.changedPromiseResolved]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['changedPromiseResolved']; - atom: AnyAtom; - oldValue: unknown; - newValue: unknown; - }>; - [AtomsLoggerEventTypes.changedPromiseRejected]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['changedPromiseRejected']; - atom: AnyAtom; - oldValue: unknown; - error: unknown; - }>; - [AtomsLoggerEventTypes.changedPromiseAborted]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['changedPromiseAborted']; - atom: AnyAtom; - oldValue: unknown; - }>; - [AtomsLoggerEventTypes.dependenciesChanged]: AtomsLoggerEventBase< - { - type: AtomsLoggerEventTypes['dependenciesChanged']; - atom: AnyAtom; - oldDependencies?: Set; - } & ( - | { addedDependency: AnyAtom; clearedDependencies?: undefined; removedDependency?: undefined } - | { addedDependency?: undefined; clearedDependencies: true; removedDependency?: undefined } - | { addedDependency?: undefined; clearedDependencies?: undefined; removedDependency: AnyAtom } - ) - >; - [AtomsLoggerEventTypes.mounted]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['mounted']; - atom: AnyAtom; - value?: unknown; - }>; - [AtomsLoggerEventTypes.unmounted]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['unmounted']; - atom: AnyAtom; - }>; - [AtomsLoggerEventTypes.destroyed]: AtomsLoggerEventBase<{ - type: AtomsLoggerEventTypes['destroyed']; - atom: AtomId; - }>; -} - -export type AtomsLoggerEvent = AtomsLoggerEventMap[keyof AtomsLoggerEventMap]; diff --git a/src/use-atoms-logger.ts b/src/use-atoms-logger.ts deleted file mode 100644 index 5b6e831..0000000 --- a/src/use-atoms-logger.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useStore } from 'jotai'; -import { useRef } from 'react'; - -import { bindAtomsLoggerToStore, isAtomsLoggerBoundToStore } from './bind-atoms-logger-to-store.js'; -import { ATOMS_LOGGER_SYMBOL } from './consts/atom-logger-symbol.js'; -import type { AtomsLoggerOptions, AtomsLoggerOptionsInState } from './types/atoms-logger.js'; -import { atomsLoggerOptionsToState } from './utils/logger-options-to-state.js'; - -/** - * Hook that logs atom state changes in the console. - * It uses the Jotai store to track atoms and their dependencies. - * - * @param options Options to configure the logger behavior with optionally a Jotai store. - * @param options.store The Jotai store to bind the logger to. If not provided, the default store from `useStore` is used. - * - * @example - * ```tsx - * function AtomsLogger() { - * useAtomsLogger(); - * return null; - * } - * ``` - */ -export function useAtomsLogger( - options?: Parameters[0] & AtomsLoggerOptions, -): void { - const store = useStore(options); - - const storeRef = useRef(store); - const prevStore = storeRef.current; - storeRef.current = store; - - // Disable the logger bound to the previous store if the store changes - if (store !== prevStore && isAtomsLoggerBoundToStore(prevStore)) { - prevStore[ATOMS_LOGGER_SYMBOL].enabled = false; - } - - // Bind the logger to the store if it is not already bound - if (options?.enabled !== false && !isAtomsLoggerBoundToStore(store)) { - bindAtomsLoggerToStore(store, options); - } - - // Update the logger options if they changes - if (isAtomsLoggerBoundToStore(store)) { - const stateOptions: AtomsLoggerOptionsInState = atomsLoggerOptionsToState(options); - Object.assign(store[ATOMS_LOGGER_SYMBOL], stateOptions); - } -} diff --git a/src/utils/convert-atoms-to-strings.ts b/src/utils/convert-atoms-to-strings.ts deleted file mode 100644 index ec06ba9..0000000 --- a/src/utils/convert-atoms-to-strings.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AnyAtom, AtomId } from '../types/atoms-logger.js'; - -export function convertAtomsToStrings( - atoms: IteratorObject | undefined, - options: { - shouldShowPrivateAtoms: boolean; - }, -): AtomId[] | undefined { - if (!atoms) { - return undefined; - } - const strings: AtomId[] = []; - for (const atom of atoms) { - if (!options.shouldShowPrivateAtoms && atom.debugPrivate === true) { - continue; - } - strings.push(atom.toString()); - } - return strings; -} diff --git a/src/utils/get-atom-value.ts b/src/utils/get-atom-value.ts deleted file mode 100644 index 785eec3..0000000 --- a/src/utils/get-atom-value.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { INTERNAL_isPromiseLike } from 'jotai/vanilla/internals'; - -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { AnyAtom, StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function getAtomValue( - store: StoreWithAtomsLogger, - atom: AnyAtom, -): { hasValue: boolean; value?: unknown } { - const state = store[ATOMS_LOGGER_SYMBOL].getState(atom); - const value = state?.v; - if (INTERNAL_isPromiseLike(value)) { - if (!store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.has(value)) { - return { hasValue: false }; - } - const promiseValue = store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.get(value); - return { hasValue: true, value: promiseValue }; - } - return { hasValue: true, value }; -} diff --git a/src/utils/should-show-atom.ts b/src/utils/should-show-atom.ts deleted file mode 100644 index 9df4967..0000000 --- a/src/utils/should-show-atom.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { AnyAtom, AtomId, StoreWithAtomsLogger } from '../types/atoms-logger.js'; - -export function shouldShowAtom(store: StoreWithAtomsLogger, atom: AnyAtom | AtomId): boolean { - if (!store[ATOMS_LOGGER_SYMBOL].enabled) { - return false; - } - if (typeof atom === 'string') { - return true; - } - if (!store[ATOMS_LOGGER_SYMBOL].shouldShowPrivateAtoms && atom.debugPrivate === true) { - return false; - } - if (store[ATOMS_LOGGER_SYMBOL].shouldShowAtom) { - return store[ATOMS_LOGGER_SYMBOL].shouldShowAtom(atom); - } - return true; -} diff --git a/src/vanilla.ts b/src/vanilla.ts new file mode 100644 index 0000000..0e75140 --- /dev/null +++ b/src/vanilla.ts @@ -0,0 +1,42 @@ +export { + createLoggedStore, + isLoggedStore, + getLoggedStoreOptions, +} from './vanilla/create-logged-store.js'; +export type { AtomLoggerOptions } from './vanilla/types/options.js'; +export type { AtomLoggerFormatter } from './vanilla/types/formatter.js'; +export { AtomTransactionTypes } from './vanilla/types/transaction.js'; +export type { + AtomTransactionType, + AtomTransactionMap, + AtomTransaction, + AtomTransactionBase, + AtomTransactionUnknown, + AtomTransactionStoreGet, + AtomTransactionStoreSet, + AtomTransactionStoreSubscribe, + AtomTransactionStoreUnsubscribe, + AtomTransactionPromiseResolved, + AtomTransactionPromiseRejected, +} from './vanilla/types/transaction.js'; +export { AtomEventTypes } from './vanilla/types/event.js'; +export type { + AtomEventType, + AtomEventMap, + AtomEvent, + AtomEventBase, + AtomEventInitialized, + AtomEventInitialPromisePending, + AtomEventInitialPromiseResolved, + AtomEventInitialPromiseRejected, + AtomEventInitialPromiseAborted, + AtomEventChanged, + AtomEventChangedPromisePending, + AtomEventChangedPromiseResolved, + AtomEventChangedPromiseRejected, + AtomEventChangedPromiseAborted, + AtomEventDependenciesChanged, + AtomEventMounted, + AtomEventUnmounted, + AtomEventDestroyed, +} from './vanilla/types/event.js'; diff --git a/src/vanilla/callbacks/on-atom-created.ts b/src/vanilla/callbacks/on-atom-created.ts new file mode 100644 index 0000000..be52d07 --- /dev/null +++ b/src/vanilla/callbacks/on-atom-created.ts @@ -0,0 +1,186 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; +import { AtomEventTypes, type AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState, Store } from '../types/store.js'; +import { filterAtoms } from '../utils/filter-atoms.js'; +import { shouldSetStateInEvent } from '../utils/should-set-state-in-event.js'; +import { shouldShowAtom } from '../utils/should-show-atom.js'; +import { onAtomValueChanged } from './on-atom-value-changed.js'; + +export function onAtomCreated( + store: Store, + buildingBlocks: Readonly, + loggerState: AtomLoggerStoreState, + atom: AnyAtom, +): void { + const atomStateMap = buildingBlocks[0]; + const atomState = atomStateMap.get(atom); + + /* v8 ignore next -- .i always fires with the atom just stored in atomStateMap -- @preserve */ + if (!atomState) return; + + let isInitialValue = true; + + if (shouldShowAtom(loggerState, atom)) { + // Track the atom for garbage collection + loggerState.atomsFinalizationRegistry.register(atom, atom.toString()); + + // Initialize the dependencies map for this atom + loggerState.dependenciesMap.set(atom, new Set()); + } + + // Track dependency additions + const originalDependentsMapSet = atomState.d.set.bind(atomState.d); + atomState.d.set = function dependentsMapSetProxy(addedDependency: AnyAtom, epochNumber: number) { + const result = originalDependentsMapSet(addedDependency, epochNumber); + + if (!shouldShowAtom(loggerState, atom) || !shouldShowAtom(loggerState, addedDependency)) + return result; + + /* v8 ignore next -- is always inside a transaction -- @preserve */ + const currentTransactionEvents = loggerState.currentTransaction?.events ?? []; + + // Update the dependencies map with the new dependency + const currentDependencies = loggerState.dependenciesMap.get(atom); + const newDependencies = new Set(currentDependencies).add(addedDependency); + loggerState.dependenciesMap.set(atom, newDependencies); + + // Update the existing dependenciesChanged event incrementally if it exists + for (const event of currentTransactionEvents) { + if (event.type === AtomEventTypes.dependenciesChanged && event.atom === atom) { + event.dependencies = newDependencies; + + event.addedDependencies ??= new Set(); + event.addedDependencies.add(addedDependency); + + /* v8 ignore next 4 -- requires d.set to fire after d.delete for the same atom in the same transaction, which cannot happen in normal Jotai flow -- @preserve */ + if (event.removedDependencies) { + event.removedDependencies.delete(addedDependency); + if (!event.removedDependencies.size) delete event.removedDependencies; + } + + return result; + } + } + + // Create a new dependenciesChanged event if there is no existing one + // and the dep is genuinely new (not already in the baseline). + const oldDependencies = loggerState.prevTransactionDependenciesMap.get(atom); + if (oldDependencies !== undefined && !oldDependencies.has(addedDependency)) { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.dependenciesChanged, + atom, + dependencies: newDependencies, + ...(oldDependencies.size ? { oldDependencies } : {}), + addedDependencies: new Set([addedDependency]), + }); + } + + return result; + }; + + // Track dependency removals + const originalDependentsMapDelete = atomState.d.delete.bind(atomState.d); + atomState.d.delete = function dependentsMapDeleteProxy(removedDependency: AnyAtom) { + const result = originalDependentsMapDelete(removedDependency); + + if (!shouldShowAtom(loggerState, atom) || !shouldShowAtom(loggerState, removedDependency)) + return result; + + /* v8 ignore next -- is always inside a transaction -- @preserve */ + const currentTransactionEvents = loggerState.currentTransaction?.events ?? []; + + // Update the dependencies map with the removed dependency + const currentDependencies = loggerState.dependenciesMap.get(atom); + const newDependencies = new Set(currentDependencies); + newDependencies.delete(removedDependency); + loggerState.dependenciesMap.set(atom, newDependencies); + + // Update the existing dependenciesChanged event incrementally if it exists + // and retroactively update existing events for this atom with the new dependencies. + // Explanation : + // In jotai 2.18+, d.delete() fires AFTER the value is set (in `pruneDependencies`) + // so the dependenciesChanged event is created after the value change events. + // This means that without this retroactive update, the dependencies in the value change events would not reflect the pruning done in the same transaction. + let hasUpdatedExistingDepsChangedEvent = false; + for (const event of currentTransactionEvents) { + if (event.atom === atom) { + if (newDependencies.size) event.dependencies = newDependencies; + else delete event.dependencies; + + if (event.type === AtomEventTypes.dependenciesChanged) { + event.removedDependencies ??= new Set(); + event.removedDependencies.add(removedDependency); + + if (event.addedDependencies) { + event.addedDependencies.delete(removedDependency); + /* v8 ignore next -- dep added then pruned in same transaction: impossible in normal Jotai flow -- @preserve */ + if (!event.addedDependencies.size) delete event.addedDependencies; + } + + hasUpdatedExistingDepsChangedEvent = true; + } + } + } + if (hasUpdatedExistingDepsChangedEvent) return result; + + // Create a new dependenciesChanged event if there is no existing one + // and the dep was genuinely in the baseline (not just added in this transaction). + const oldDependencies = loggerState.prevTransactionDependenciesMap.get(atom); + if (oldDependencies?.has(removedDependency)) { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.dependenciesChanged, + atom, + ...(newDependencies.size ? { dependencies: newDependencies } : {}), + oldDependencies, + removedDependencies: new Set([removedDependency]), + }); + } + + return result; + }; + + // Track pending promise additions + const originalPendingPromisesAdd = atomState.p.add.bind(atomState.p); + atomState.p.add = function pendingPromisesAddProxy(pendingAtom: AnyAtom) { + const result = originalPendingPromisesAdd(pendingAtom); + + if (!shouldShowAtom(loggerState, atom) || !shouldShowAtom(loggerState, pendingAtom)) + return result; + + /* v8 ignore next -- is always inside a transaction -- @preserve */ + const currentTransactionEvents = loggerState.currentTransaction?.events ?? []; + + // Retroactively update existing events for this atom with the new pending promise + const pendingPromises = filterAtoms(atomState.p, loggerState); + for (const event of currentTransactionEvents) { + if (event.atom === atom && shouldSetStateInEvent(event)) { + /* v8 ignore next -- pendingAtom passed shouldShowAtom so filterAtoms always returns non-empty -- @preserve */ + if (pendingPromises?.size) event.pendingPromises = pendingPromises; + else delete event.pendingPromises; + } + } + + return result; + }; + + // Track the values changes in the atom state. + const stateProxy = new Proxy(atomState, { + set(target, _prop, newValue: unknown, receiver) { + const prop = _prop as keyof typeof target; + if (prop === 'v') { + const oldValue = Reflect.get(target, prop, receiver); + onAtomValueChanged(store, loggerState, buildingBlocks, atom, { + isInitialValue, + oldValue, + newValue, + }); + isInitialValue = false; + } + return Reflect.set(target, prop, newValue); + }, + }); + + atomStateMap.set(atom, stateProxy); +} diff --git a/src/vanilla/callbacks/on-atom-garbage-collected.ts b/src/vanilla/callbacks/on-atom-garbage-collected.ts new file mode 100644 index 0000000..9b59b42 --- /dev/null +++ b/src/vanilla/callbacks/on-atom-garbage-collected.ts @@ -0,0 +1,16 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; +import { AtomEventTypes, type AtomId } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function onAtomGarbageCollected( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + atom: AtomId, +): void { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.destroyed, + atom, + }); +} diff --git a/src/vanilla/callbacks/on-atom-mounted.ts b/src/vanilla/callbacks/on-atom-mounted.ts new file mode 100644 index 0000000..0f60176 --- /dev/null +++ b/src/vanilla/callbacks/on-atom-mounted.ts @@ -0,0 +1,61 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; +import { AtomEventTypes, type AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; +import { filterAtoms } from '../utils/filter-atoms.js'; +import { getAtomValue } from '../utils/get-atom-value.js'; +import { shouldSetStateInEvent } from '../utils/should-set-state-in-event.js'; +import { shouldShowAtom } from '../utils/should-show-atom.js'; + +export function onAtomMounted( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + atom: AnyAtom, +) { + const { hasValue, value } = getAtomValue(loggerState, buildingBlocks, atom); + if (hasValue) { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.mounted, + atom, + value, + }); + } else { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.mounted, + atom, + }); + } + + // Track dependents added to the mounted atom. + // The dependents tracking is on mount since dependents are only relevant when the atom is mounted. + // On the other hand, dependencies are tracked on atom creation since they are relevant even when the atom is not mounted. + + if (!shouldShowAtom(loggerState, atom)) return; + + const mountedMap = buildingBlocks[1]; + const mounted = mountedMap.get(atom); + + /* v8 ignore next -- mountedState always exists when the mount hook fires -- @preserve */ + if (!mounted) return; + + // Track dependents added to the mounted atom + const originalMountedAdd = mounted.t.add.bind(mounted.t); + mounted.t.add = function mountedAddProxy(dependentAtom: AnyAtom) { + const result = originalMountedAdd(dependentAtom); + + /* v8 ignore next -- mountedAddProxy fires during mountAtom which is always within a transaction -- @preserve */ + const currentTransactionEvents = loggerState.currentTransaction?.events ?? []; + + // Retroactively update existing events for this atom with the new dependents + const dependents = filterAtoms(mounted.t, loggerState); + for (const event of currentTransactionEvents) { + if (event.atom === atom && shouldSetStateInEvent(event)) { + if (dependents?.size) event.dependents = dependents; + else delete event.dependents; + } + } + + return result; + }; +} diff --git a/src/vanilla/callbacks/on-atom-unmounted.ts b/src/vanilla/callbacks/on-atom-unmounted.ts new file mode 100644 index 0000000..2c6db63 --- /dev/null +++ b/src/vanilla/callbacks/on-atom-unmounted.ts @@ -0,0 +1,16 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; +import { AtomEventTypes, type AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function onAtomUnmounted( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + atom: AnyAtom, +) { + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.unmounted, + atom, + }); +} diff --git a/src/callbacks/on-atom-value-changed.ts b/src/vanilla/callbacks/on-atom-value-changed.ts similarity index 58% rename from src/callbacks/on-atom-value-changed.ts rename to src/vanilla/callbacks/on-atom-value-changed.ts index 26e8a45..cea8259 100644 --- a/src/callbacks/on-atom-value-changed.ts +++ b/src/vanilla/callbacks/on-atom-value-changed.ts @@ -1,18 +1,17 @@ -import { INTERNAL_isPromiseLike } from 'jotai/vanilla/internals'; +import { INTERNAL_isPromiseLike as isPromiseLike } from 'jotai/vanilla/internals'; +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; import { addEventToTransaction } from '../transactions/add-event-to-transaction.js'; import { endTransaction } from '../transactions/end-transaction.js'; import { startTransaction } from '../transactions/start-transaction.js'; -import { - AtomsLoggerEventTypes, - AtomsLoggerTransactionTypes, - type AnyAtom, - type StoreWithAtomsLogger, -} from '../types/atoms-logger.js'; +import { AtomEventTypes, type AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState, Store } from '../types/store.js'; +import { AtomTransactionTypes } from '../types/transaction.js'; export function onAtomValueChanged( - store: StoreWithAtomsLogger, + store: Store, + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, atom: AnyAtom, args: { isInitialValue?: boolean; oldValue?: unknown; newValue: unknown }, ) { @@ -20,17 +19,17 @@ export function onAtomValueChanged( let { isInitialValue = false } = args; let { oldValue } = args; - if (!INTERNAL_isPromiseLike(newValueOrPromise)) { + if (!isPromiseLike(newValueOrPromise)) { const newValue = newValueOrPromise; if (isInitialValue) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.initialized, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.initialized, atom, value: newValue, }); } else if (oldValue !== newValueOrPromise) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.changed, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.changed, atom, oldValue, newValue, @@ -42,10 +41,10 @@ export function onAtomValueChanged( const newPromise = newValueOrPromise; if (!isInitialValue) { - if (INTERNAL_isPromiseLike(oldValue)) { - if (store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.has(oldValue)) { + if (isPromiseLike(oldValue)) { + if (loggerState.promisesResultsMap.has(oldValue)) { // uses the result of the previous promise instead of the promise itself - oldValue = store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.get(oldValue); + oldValue = loggerState.promisesResultsMap.get(oldValue); } else { // Edge case to know if the current promise is still the initial promise after the previous (initial) promise was aborted : // if we don't have the result of the previous promise it means that is @@ -61,44 +60,45 @@ export function onAtomValueChanged( // - If this promise is aborted, the oldValue will be retrieved from the results map in the above code. // This also prevent the new promise to think it was an initial promise is the above edge case. // - Else, it will be replaced by the new value or error of the resolved/rejected promise. - store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.set(newPromise, oldValue); + loggerState.promisesResultsMap.set(newPromise, oldValue); } let isAborted = false; if (isInitialValue) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.initialPromisePending, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.initialPromisePending, atom, }); } else { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.changedPromisePending, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.changedPromisePending, atom, oldValue, }); } - store[ATOMS_LOGGER_SYMBOL].registerAbortHandler(store, newPromise, () => { + const registerAbortHandler = buildingBlocks[26]; + registerAbortHandler(buildingBlocks, store, newPromise, () => { isAborted = true; if (isInitialValue) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.initialPromiseAborted, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.initialPromiseAborted, atom, }); } else { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.changedPromiseAborted, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.changedPromiseAborted, atom, oldValue, }); } }); - const transactionWhenPending = store[ATOMS_LOGGER_SYMBOL].currentTransaction; + const transactionWhenPending = loggerState.currentTransaction; const canStartNewTransaction = () => { - const currentTransaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction; + const currentTransaction = loggerState.currentTransaction; // No transaction started : start a new one if (currentTransaction === undefined) { @@ -115,8 +115,8 @@ export function onAtomValueChanged( // that these promises were waiting for the previous pending transaction to // be settled so merge them into the current transaction. if ( - currentTransaction.type === AtomsLoggerTransactionTypes.promiseResolved || - currentTransaction.type === AtomsLoggerTransactionTypes.promiseRejected + currentTransaction.type === AtomTransactionTypes.promiseResolved || + currentTransaction.type === AtomTransactionTypes.promiseRejected ) { return false; } @@ -128,26 +128,26 @@ export function onAtomValueChanged( newPromise.then( (newValue: unknown) => { if (!isAborted) { - store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.set(newPromise, newValue); + loggerState.promisesResultsMap.set(newPromise, newValue); const doStartTransaction = canStartNewTransaction(); if (doStartTransaction) { - startTransaction(store, { - type: AtomsLoggerTransactionTypes.promiseResolved, + startTransaction(loggerState, { + type: AtomTransactionTypes.promiseResolved, atom, }); } if (isInitialValue) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.initialPromiseResolved, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.initialPromiseResolved, atom, value: newValue, }); } else { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.changedPromiseResolved, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.changedPromiseResolved, atom, oldValue, newValue, @@ -155,7 +155,7 @@ export function onAtomValueChanged( } if (doStartTransaction) { - endTransaction(store); + endTransaction(loggerState); } } }, @@ -164,23 +164,23 @@ export function onAtomValueChanged( const doStartTransaction = canStartNewTransaction(); if (doStartTransaction) { - startTransaction(store, { - type: AtomsLoggerTransactionTypes.promiseRejected, + startTransaction(loggerState, { + type: AtomTransactionTypes.promiseRejected, atom, }); } - store[ATOMS_LOGGER_SYMBOL].promisesResultsMap.set(newPromise, error); + loggerState.promisesResultsMap.set(newPromise, error); if (isInitialValue) { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.initialPromiseRejected, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.initialPromiseRejected, atom, error, }); } else { - addEventToTransaction(store, { - type: AtomsLoggerEventTypes.changedPromiseRejected, + addEventToTransaction(loggerState, buildingBlocks, { + type: AtomEventTypes.changedPromiseRejected, atom, oldValue, error, @@ -188,7 +188,7 @@ export function onAtomValueChanged( } if (doStartTransaction) { - endTransaction(store); + endTransaction(loggerState); } } }, diff --git a/src/vanilla/callbacks/on-store-get.ts b/src/vanilla/callbacks/on-store-get.ts new file mode 100644 index 0000000..923fb27 --- /dev/null +++ b/src/vanilla/callbacks/on-store-get.ts @@ -0,0 +1,27 @@ +import type { Atom } from 'jotai'; +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { endTransaction } from '../transactions/end-transaction.js'; +import { startTransaction } from '../transactions/start-transaction.js'; +import type { AtomLoggerStoreState, Store } from '../types/store.js'; +import { AtomTransactionTypes } from '../types/transaction.js'; + +export function onStoreGet( + parentStoreGet: BuildingBlocks[21], + store: Store, + buildingBlocks: Readonly, + loggerState: AtomLoggerStoreState, + atom: Atom, +): TValue { + const doStartTransaction = !loggerState.isInsideTransaction; + try { + if (doStartTransaction) { + startTransaction(loggerState, { type: AtomTransactionTypes.storeGet, atom }); + } + return parentStoreGet(buildingBlocks, store, atom); + } finally { + if (doStartTransaction) { + endTransaction(loggerState); + } + } +} diff --git a/src/vanilla/callbacks/on-store-set.ts b/src/vanilla/callbacks/on-store-set.ts new file mode 100644 index 0000000..4c5f1a3 --- /dev/null +++ b/src/vanilla/callbacks/on-store-set.ts @@ -0,0 +1,36 @@ +import type { WritableAtom } from 'jotai'; +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { endTransaction } from '../transactions/end-transaction.js'; +import { startTransaction } from '../transactions/start-transaction.js'; +import type { AtomLoggerStoreState, Store } from '../types/store.js'; +import { AtomTransactionTypes } from '../types/transaction.js'; + +export function onStoreSet( + parentStoreSet: BuildingBlocks[22], + store: Store, + buildingBlocks: Readonly, + loggerState: AtomLoggerStoreState, + atom: WritableAtom, + ...args: TArgs +) { + const doStartTransaction = !loggerState.isInsideTransaction; + try { + const transaction = { + type: AtomTransactionTypes.storeSet, + atom, + args, + result: undefined as unknown, + }; + if (doStartTransaction) { + startTransaction(loggerState, transaction); + } + const result = parentStoreSet(buildingBlocks, store, atom, ...args); + transaction.result = result; + return result; + } finally { + if (doStartTransaction) { + endTransaction(loggerState); + } + } +} diff --git a/src/vanilla/callbacks/on-store-sub.ts b/src/vanilla/callbacks/on-store-sub.ts new file mode 100644 index 0000000..130e2a1 --- /dev/null +++ b/src/vanilla/callbacks/on-store-sub.ts @@ -0,0 +1,58 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { endTransaction } from '../transactions/end-transaction.js'; +import { startTransaction } from '../transactions/start-transaction.js'; +import type { AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState, Store } from '../types/store.js'; +import { AtomTransactionTypes } from '../types/transaction.js'; + +export function onStoreSub( + parentStoreSub: BuildingBlocks[23], + store: Store, + buildingBlocks: Readonly, + loggerState: AtomLoggerStoreState, + atom: AnyAtom, + listener: () => void, +): () => void { + const doStartTransaction = !loggerState.isInsideTransaction; + try { + if (doStartTransaction) { + startTransaction(loggerState, { + type: AtomTransactionTypes.storeSubscribe, + atom, + listener, + }); + } + const unsubscribe = parentStoreSub(buildingBlocks, store, atom, listener); + return () => { + onStoreUnsubscribe(loggerState, atom, listener, unsubscribe); + }; + } finally { + if (doStartTransaction) { + endTransaction(loggerState); + } + } +} + +function onStoreUnsubscribe( + loggerState: AtomLoggerStoreState, + atom: AnyAtom, + listener: () => void, + unsubscribe: () => void, +) { + const doStartTransaction = !loggerState.isInsideTransaction; + try { + if (doStartTransaction) { + startTransaction(loggerState, { + type: AtomTransactionTypes.storeUnsubscribe, + atom, + listener, + }); + } + unsubscribe(); + } finally { + if (doStartTransaction) { + endTransaction(loggerState); + } + } +} diff --git a/src/vanilla/consts/default-options.ts b/src/vanilla/consts/default-options.ts new file mode 100644 index 0000000..e10b1e1 --- /dev/null +++ b/src/vanilla/consts/default-options.ts @@ -0,0 +1,6 @@ +export const DEFAULT_ENABLED = true; +export const DEFAULT_SHOULD_SHOW_PRIVATE_ATOMS = false; +export const DEFAULT_SYNCHRONOUS = false; +export const DEFAULT_TRANSACTION_DEBOUNCE_MS = 250; +export const DEFAULT_REQUEST_IDLE_CALLBACK_TIMEOUT_MS = 250; +export const DEFAULT_MAX_PROCESSING_TIME_MS = 16; diff --git a/src/vanilla/create-logged-store.ts b/src/vanilla/create-logged-store.ts new file mode 100644 index 0000000..04bf112 --- /dev/null +++ b/src/vanilla/create-logged-store.ts @@ -0,0 +1,171 @@ +import { + INTERNAL_buildStoreRev3 as buildStore, + INTERNAL_getBuildingBlocksRev3 as getBuildingBlocks, + INTERNAL_initializeStoreHooksRev3 as initializeStoreHooks, +} from 'jotai/vanilla/internals'; + +import { consoleFormatter } from '../formatters/console/index.js'; +import { onAtomCreated } from './callbacks/on-atom-created.js'; +import { onAtomGarbageCollected } from './callbacks/on-atom-garbage-collected.js'; +import { onAtomMounted } from './callbacks/on-atom-mounted.js'; +import { onAtomUnmounted } from './callbacks/on-atom-unmounted.js'; +import { onStoreGet } from './callbacks/on-store-get.js'; +import { onStoreSet } from './callbacks/on-store-set.js'; +import { onStoreSub } from './callbacks/on-store-sub.js'; +import { + DEFAULT_ENABLED, + DEFAULT_MAX_PROCESSING_TIME_MS, + DEFAULT_REQUEST_IDLE_CALLBACK_TIMEOUT_MS, + DEFAULT_SHOULD_SHOW_PRIVATE_ATOMS, + DEFAULT_SYNCHRONOUS, + DEFAULT_TRANSACTION_DEBOUNCE_MS, +} from './consts/default-options.js'; +import { createLogTransactionsScheduler } from './log-transactions-scheduler.js'; +import type { AtomId } from './types/event.js'; +import type { AtomLoggerOptions } from './types/options.js'; +import type { Store, AtomLoggerStoreState } from './types/store.js'; + +/** + * Internal WeakMap to track which stores are logged stores created by {@link createLoggedStore}, and to access their logger state. + */ +const loggedStoreStates = new WeakMap(); + +/** + * Create a new Jotai store that shares state with the given parent store but intercepts all `get`, `set` and `sub` calls to log atom transactions. + * + * @param parentStore The parent Jotai store to derive from. + * @param options Mutable options object for the atom logger. Defaults are applied in-place. + * @returns A new store that shares state with the parent but has logging enabled. + * + * @throws If the provided parentStore is not a valid Jotai store. + * + * @example + * ```ts + * const parentStore = createStore(); + * const options = { enabled: true }; + * const loggedStore = createLoggedStore(parentStore, options); + * loggedStore.get(someAtom); // This call will be logged. + * options.enabled = false; // Disable logging at runtime. + * ``` + */ +export function createLoggedStore(parentStore: Store, options: AtomLoggerOptions = {}): Store { + options.enabled ??= DEFAULT_ENABLED; + options.shouldShowPrivateAtoms ??= DEFAULT_SHOULD_SHOW_PRIVATE_ATOMS; + options.synchronous ??= DEFAULT_SYNCHRONOUS; + options.transactionDebounceMs ??= DEFAULT_TRANSACTION_DEBOUNCE_MS; + options.requestIdleCallbackTimeoutMs ??= DEFAULT_REQUEST_IDLE_CALLBACK_TIMEOUT_MS; + options.maxProcessingTimeMs ??= DEFAULT_MAX_PROCESSING_TIME_MS; + options.formatter ??= consoleFormatter(); + + const logTransactionsScheduler = createLogTransactionsScheduler(options); + + const atomsFinalizationRegistry = new FinalizationRegistry((atomId: AtomId) => { + onAtomGarbageCollected(loggerState, buildingBlocks, atomId); + }); + + const loggerState: AtomLoggerStoreState = { + options, + logTransactionsScheduler, + transactionNumber: 1, + currentTransaction: undefined, + isInsideTransaction: false, + atomsFinalizationRegistry, + promisesResultsMap: new WeakMap(), + dependenciesMap: new WeakMap(), + prevTransactionDependenciesMap: new WeakMap(), + transactionsDebounceTimeoutId: undefined, + }; + + const parentBuildingBlocks = getBuildingBlocks(parentStore); + const parentStoreGet = parentBuildingBlocks[21]; + const parentStoreSet = parentBuildingBlocks[22]; + const parentStoreSub = parentBuildingBlocks[23]; + + const storeHooks = initializeStoreHooks({}); + + storeHooks.i.add(undefined, (atom) => { + onAtomCreated(loggedStore, buildingBlocks, loggerState, atom); + }); + storeHooks.m.add(undefined, (atom) => { + onAtomMounted(loggerState, buildingBlocks, atom); + }); + storeHooks.u.add(undefined, (atom) => { + onAtomUnmounted(loggerState, buildingBlocks, atom); + }); + + const loggedStore = buildStore( + parentBuildingBlocks[0], + parentBuildingBlocks[1], + parentBuildingBlocks[2], + parentBuildingBlocks[3], + parentBuildingBlocks[4], + parentBuildingBlocks[5], + storeHooks, + parentBuildingBlocks[7], + parentBuildingBlocks[8], + parentBuildingBlocks[9], + parentBuildingBlocks[10], + parentBuildingBlocks[11], + parentBuildingBlocks[12], + parentBuildingBlocks[13], + parentBuildingBlocks[14], + parentBuildingBlocks[15], + parentBuildingBlocks[16], + parentBuildingBlocks[17], + parentBuildingBlocks[18], + parentBuildingBlocks[19], + parentBuildingBlocks[20], + (buildingBlocks, store, ...args) => { + return onStoreGet(parentStoreGet, store, buildingBlocks, loggerState, ...args); + }, + (buildingBlocks, store, ...args) => { + return onStoreSet(parentStoreSet, store, buildingBlocks, loggerState, ...args); + }, + (buildingBlocks, store, ...args) => { + return onStoreSub(parentStoreSub, store, buildingBlocks, loggerState, ...args); + }, + parentBuildingBlocks[24], + parentBuildingBlocks[25], + parentBuildingBlocks[26], + parentBuildingBlocks[27], + parentBuildingBlocks[28], + ); + + const buildingBlocks = getBuildingBlocks(loggedStore); + + loggedStoreStates.set(loggedStore, loggerState); + + return loggedStore; +} + +/** + * Check if a given store is a logged store created by `createLoggedStore`. + * + * @param store The Jotai store to check. + * @returns `true` if the store is a logged store created by `createLoggedStore`, `false` otherwise. + */ +export function isLoggedStore(store: Store): boolean { + return loggedStoreStates.has(store); +} + +/** + * @internal Exposes the raw internal logger state. + */ +export function getLoggedStoreState(store: Store): AtomLoggerStoreState | undefined { + return loggedStoreStates.get(store); +} + +/** + * Get the current logger options for a logged store. Returns `undefined` if the store is not a logged store. + * + * @remarks + * The returned options object is the same mutable object that was passed to `createLoggedStore` (with defaults applied). + * Modifying it will affect the behavior of the logger at runtime. + * + * @param store The Jotai store to get the logger options from. + * @returns The current logger options for the logged store, or `undefined` if the store is not a logged store. + */ +export function getLoggedStoreOptions(store: Store): AtomLoggerOptions | undefined { + const loggerState = getLoggedStoreState(store); + return loggerState?.options; +} diff --git a/src/vanilla/log-transactions-scheduler.ts b/src/vanilla/log-transactions-scheduler.ts new file mode 100644 index 0000000..e76ff44 --- /dev/null +++ b/src/vanilla/log-transactions-scheduler.ts @@ -0,0 +1,90 @@ +import type { AtomLoggerOptions } from './types/options.js'; +import type { AtomLoggerStoreState } from './types/store.js'; + +// Check the time every N processed transactions to avoid doing it too often. +const checkTimeInterval = 10; + +export function createLogTransactionsScheduler( + loggerOptions: Pick< + AtomLoggerOptions, + 'formatter' | 'synchronous' | 'requestIdleCallbackTimeoutMs' | 'maxProcessingTimeMs' + >, +): AtomLoggerStoreState['logTransactionsScheduler'] { + const logTransactionsScheduler: AtomLoggerStoreState['logTransactionsScheduler'] = { + queue: [], + isProcessing: false, + process(this: AtomLoggerStoreState['logTransactionsScheduler']) { + if (this.isProcessing || this.queue.length === 0) return; + + let maxProcessingTimeMs: number; + if (loggerOptions.synchronous || loggerOptions.maxProcessingTimeMs === undefined) { + maxProcessingTimeMs = -1; + } else { + maxProcessingTimeMs = loggerOptions.maxProcessingTimeMs; + } + + this.isProcessing = true; + schedule( + () => { + try { + const startTime = maxProcessingTimeMs > 0 ? performance.now() : -1; // Not used if maxProcessingTimeMs <= 0 + + let processedCount = 0; + while (this.queue.length > 0) { + const transaction = this.queue.shift(); + if (transaction) { + loggerOptions.formatter?.(transaction); + processedCount += 1; + + // Stop processing if we reached the max processing time + if ( + maxProcessingTimeMs > 0 && + processedCount % checkTimeInterval === 0 && + performance.now() - startTime >= maxProcessingTimeMs + ) { + break; + } + } + } + } finally { + this.isProcessing = false; + + // Continue processing if there are still transactions in the queue + if (this.queue.length > 0) { + this.process(); + } + } + }, + { + synchronous: loggerOptions.synchronous, + requestIdleCallbackTimeoutMs: loggerOptions.requestIdleCallbackTimeoutMs, + }, + ); + }, + add(transaction) { + this.queue.push(transaction); + this.process(); + }, + }; + return logTransactionsScheduler; +} + +function schedule( + cb: () => void, + { + synchronous, + requestIdleCallbackTimeoutMs, + }: Pick, +): void { + if ( + synchronous || + requestIdleCallbackTimeoutMs === undefined || + requestIdleCallbackTimeoutMs <= -1 + ) { + cb(); + } else if (typeof globalThis.requestIdleCallback === 'function') { + globalThis.requestIdleCallback(cb, { timeout: requestIdleCallbackTimeoutMs }); + } else { + setTimeout(cb, 0); + } +} diff --git a/src/vanilla/transactions/add-event-to-transaction.ts b/src/vanilla/transactions/add-event-to-transaction.ts new file mode 100644 index 0000000..f0fee10 --- /dev/null +++ b/src/vanilla/transactions/add-event-to-transaction.ts @@ -0,0 +1,95 @@ +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import { AtomEventTypes, type AtomEvent } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; +import { AtomTransactionTypes, type AtomTransaction } from '../types/transaction.js'; +import { filterAtoms } from '../utils/filter-atoms.js'; +import { shouldSetStateInEvent } from '../utils/should-set-state-in-event.js'; +import { shouldShowAtom } from '../utils/should-show-atom.js'; +import { debounceEndTransaction } from './debounce-end-transaction.js'; +import { endTransaction } from './end-transaction.js'; +import { startTransaction } from './start-transaction.js'; + +export function addEventToTransaction( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + event: AtomEvent, +): void { + if (!shouldShowAtom(loggerState, event.atom)) { + return; + } + + setStateInEvent(loggerState, buildingBlocks, event); + + const transaction = loggerState.currentTransaction; + + if (!transaction) { + // Execute the event in an independent "unknown" transaction if there is no current transaction. + startTransaction(loggerState, { type: AtomTransactionTypes.unknown, atom: event.atom }); + addEventToTransaction(loggerState, buildingBlocks, event); + endTransaction(loggerState); + return; + } + + // Debounce the transaction since a new event is added to it. + if (loggerState.transactionsDebounceTimeoutId !== undefined) { + debounceEndTransaction(loggerState); + } + + // Add the event to the current transaction. + transaction.events.push(event); + + // Reorder the events in the transaction. + reversePromiseAbortedAndPending(transaction, event); +} + +/** + * Set the state of the atom in the event. + */ +function setStateInEvent( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + event: AtomEvent, +): void { + if (typeof event.atom === 'string' || !shouldSetStateInEvent(event)) return; + + const dependencies = loggerState.dependenciesMap.get(event.atom); + if (dependencies?.size) event.dependencies = dependencies; + + const parentMountedMap = buildingBlocks[1]; + const mountedState = parentMountedMap.get(event.atom); + const dependents = filterAtoms(mountedState?.t, loggerState); + if (dependents?.size) event.dependents = dependents; + + const parentAtomStateMap = buildingBlocks[0]; + const atomState = parentAtomStateMap.get(event.atom); + const pendingPromises = filterAtoms(atomState?.p, loggerState); + if (pendingPromises?.size) event.pendingPromises = pendingPromises; +} + +/** + * HACK: logs that a promise was aborted before a new one is pending + * + * In Jotai's code (`setAtomStateValueOrPromise`) the value of the promise is set **before** the abort event is triggered. + * This means that the abort event is added in the transaction after the new pending promise event. + * This hack just swap their order to make the log more readable. + */ +function reversePromiseAbortedAndPending(transaction: AtomTransaction, event: AtomEvent): void { + if ( + event.type === AtomEventTypes.initialPromiseAborted || + event.type === AtomEventTypes.changedPromiseAborted + ) { + const events = transaction.events; + /* v8 ignore next -- abort-as-only-event: the abort fires alone without a prior pending in the same transaction, which cannot happen in normal jotai usage -- @preserve */ + if (events.length > 1) { + const eventBeforeAbort = events[events.length - 2]; + if ( + eventBeforeAbort?.type === AtomEventTypes.initialPromisePending || + eventBeforeAbort?.type === AtomEventTypes.changedPromisePending + ) { + events[events.length - 2] = event; + events[events.length - 1] = eventBeforeAbort; + } + } + } +} diff --git a/src/vanilla/transactions/debounce-end-transaction.ts b/src/vanilla/transactions/debounce-end-transaction.ts new file mode 100644 index 0000000..95e9247 --- /dev/null +++ b/src/vanilla/transactions/debounce-end-transaction.ts @@ -0,0 +1,16 @@ +import type { AtomLoggerStoreState } from '../types/store.js'; +import { flushTransactionEvents } from './flush-transaction-events.js'; +import { stopEndTransactionDebounce } from './stop-end-transaction-debounce.js'; +import { updateTransactionEndTimestamp } from './update-transaction-end-timestamp.js'; + +export function debounceEndTransaction(loggerState: AtomLoggerStoreState) { + stopEndTransactionDebounce(loggerState); + + // Store the transaction end timestamp BEFORE debouncing + updateTransactionEndTimestamp(loggerState); + + loggerState.transactionsDebounceTimeoutId = setTimeout(() => { + loggerState.transactionsDebounceTimeoutId = undefined; + flushTransactionEvents(loggerState); + }, loggerState.options.transactionDebounceMs); +} diff --git a/src/transactions/end-transaction.ts b/src/vanilla/transactions/end-transaction.ts similarity index 69% rename from src/transactions/end-transaction.ts rename to src/vanilla/transactions/end-transaction.ts index 23f2950..a90830c 100644 --- a/src/transactions/end-transaction.ts +++ b/src/vanilla/transactions/end-transaction.ts @@ -1,27 +1,26 @@ -import { ATOMS_LOGGER_SYMBOL } from '../consts/atom-logger-symbol.js'; -import type { StoreWithAtomsLogger } from '../types/atoms-logger.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; import { debounceEndTransaction } from './debounce-end-transaction.js'; import { flushTransactionEvents } from './flush-transaction-events.js'; import { stopEndTransactionDebounce } from './stop-end-transaction-debounce.js'; import { updateTransactionEndTimestamp } from './update-transaction-end-timestamp.js'; export function endTransaction( - store: StoreWithAtomsLogger, + loggerState: AtomLoggerStoreState, { immediate } = { immediate: false }, ): void { - store[ATOMS_LOGGER_SYMBOL].isInsideTransaction = false; + loggerState.isInsideTransaction = false; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should never happen since it is called after startTransaction - const transaction = store[ATOMS_LOGGER_SYMBOL].currentTransaction!; + const transaction = loggerState.currentTransaction!; // Retrieve the owner stack if there are events to log (for better logging performance) if ( - transaction.eventsCount > 0 && + transaction.events.length > 0 && !transaction.ownerStack && - store[ATOMS_LOGGER_SYMBOL].getOwnerStack + loggerState.options.getOwnerStack ) { try { - transaction.ownerStack = store[ATOMS_LOGGER_SYMBOL].getOwnerStack(); + transaction.ownerStack = loggerState.options.getOwnerStack(); } catch { transaction.ownerStack = undefined; } @@ -42,22 +41,27 @@ export function endTransaction( * To compare the previous and next value of the atom, it calls `store.get`. * This store call is done in the component body (inside `useReducer`), so the component display name can be retrieved here. */ - if (!transaction.componentDisplayName && store[ATOMS_LOGGER_SYMBOL].getComponentDisplayName) { + if (!transaction.componentDisplayName && loggerState.options.getComponentDisplayName) { try { - transaction.componentDisplayName = store[ATOMS_LOGGER_SYMBOL].getComponentDisplayName(); + transaction.componentDisplayName = loggerState.options.getComponentDisplayName(); } catch { transaction.componentDisplayName = undefined; } } // Flush the transaction events immediately (useful when starting a new transaction). - if (immediate || store[ATOMS_LOGGER_SYMBOL].transactionDebounceMs <= 0) { - stopEndTransactionDebounce(store); - updateTransactionEndTimestamp(store); - flushTransactionEvents(store); + if ( + immediate || + loggerState.options.synchronous || + loggerState.options.transactionDebounceMs === undefined || + loggerState.options.transactionDebounceMs <= 0 + ) { + stopEndTransactionDebounce(loggerState); + updateTransactionEndTimestamp(loggerState); + flushTransactionEvents(loggerState); return; } // Start a new transaction debounce timeout. - debounceEndTransaction(store); + debounceEndTransaction(loggerState); } diff --git a/src/vanilla/transactions/flush-transaction-events.ts b/src/vanilla/transactions/flush-transaction-events.ts new file mode 100644 index 0000000..3bef808 --- /dev/null +++ b/src/vanilla/transactions/flush-transaction-events.ts @@ -0,0 +1,45 @@ +import { AtomEventTypes } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; +import type { AtomTransaction } from '../types/transaction.js'; + +export function flushTransactionEvents(loggerState: AtomLoggerStoreState): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should never happen since it is called in endTransaction + const transaction = loggerState.currentTransaction!; + + loggerState.currentTransaction = undefined; + + // Update the previous dependency changed events for the current transaction. + updatePreviousDependencyChangedEvents(loggerState, transaction); + + // If the transaction has no events, we don't need to log it. + if (transaction.events.length <= 0) { + return; + } + + // Only increment the transaction number if the current transaction is logged + loggerState.transactionNumber += 1; + + // Add current transaction to scheduler instead of executing immediately + loggerState.logTransactionsScheduler.add(transaction); +} + +function updatePreviousDependencyChangedEvents( + loggerState: AtomLoggerStoreState, + transaction: AtomTransaction, +): void { + for (const event of transaction.events) { + if (event.type === AtomEventTypes.dependenciesChanged) { + // Update the previous dependencies with the new dependencies for the next transaction. + loggerState.prevTransactionDependenciesMap.set(event.atom, event.dependencies ?? new Set()); + } else if (event.type === AtomEventTypes.initialized) { + // Atoms initialized with only private deps produce no dependenciesChanged events, so + // prevTransactionDependenciesMap is never set for them. Initialize it here so that + // future dep additions can be correctly detected. + const dependencies = loggerState.dependenciesMap.get(event.atom); + /* v8 ignore next 3 -- dependenciesMap is always initialized for visible atoms in onAtomCreated -- @preserve */ + if (dependencies !== undefined) { + loggerState.prevTransactionDependenciesMap.set(event.atom, dependencies); + } + } + } +} diff --git a/src/vanilla/transactions/start-transaction.ts b/src/vanilla/transactions/start-transaction.ts new file mode 100644 index 0000000..9c269b4 --- /dev/null +++ b/src/vanilla/transactions/start-transaction.ts @@ -0,0 +1,49 @@ +import type { AtomLoggerStoreState } from '../types/store.js'; +import type { AtomTransaction, AtomTransactionMap } from '../types/transaction.js'; +import { shouldShowAtom } from '../utils/should-show-atom.js'; +import { endTransaction } from './end-transaction.js'; + +export function startTransaction( + loggerState: AtomLoggerStoreState, + partialTransaction: { + [K in keyof AtomTransactionMap]: Omit< + AtomTransactionMap[K], + | 'transactionNumber' + | 'events' + | 'startTimestamp' + | 'endTimestamp' + | 'ownerStack' + | 'componentDisplayName' + >; + }[keyof AtomTransactionMap], +): void { + if (loggerState.currentTransaction) { + // Finish the previous transaction immediately to start a new one. + endTransaction(loggerState, { immediate: true }); + } + + loggerState.isInsideTransaction = true; + + const transaction = partialTransaction as AtomTransaction; + + transaction.transactionNumber = loggerState.transactionNumber; + transaction.events = []; + + transaction.startTimestamp = performance.now(); + + if (!transaction.componentDisplayName && loggerState.options.getComponentDisplayName) { + try { + // Try to get the component display name. + // Do it at the start AND the end of the transaction to cover more cases since this can fail. + transaction.componentDisplayName = loggerState.options.getComponentDisplayName(); + } catch { + transaction.componentDisplayName = undefined; + } + } + + if (transaction.atom && !shouldShowAtom(loggerState, transaction.atom)) { + transaction.atom = undefined; + } + + loggerState.currentTransaction = transaction; +} diff --git a/src/vanilla/transactions/stop-end-transaction-debounce.ts b/src/vanilla/transactions/stop-end-transaction-debounce.ts new file mode 100644 index 0000000..2f913a8 --- /dev/null +++ b/src/vanilla/transactions/stop-end-transaction-debounce.ts @@ -0,0 +1,8 @@ +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function stopEndTransactionDebounce(loggerState: AtomLoggerStoreState) { + if (loggerState.transactionsDebounceTimeoutId !== undefined) { + clearTimeout(loggerState.transactionsDebounceTimeoutId); + loggerState.transactionsDebounceTimeoutId = undefined; + } +} diff --git a/src/vanilla/transactions/update-transaction-end-timestamp.ts b/src/vanilla/transactions/update-transaction-end-timestamp.ts new file mode 100644 index 0000000..aed6b9e --- /dev/null +++ b/src/vanilla/transactions/update-transaction-end-timestamp.ts @@ -0,0 +1,7 @@ +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function updateTransactionEndTimestamp(loggerState: AtomLoggerStoreState): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- should never happen since it is called after startTransaction + const transaction = loggerState.currentTransaction!; + transaction.endTimestamp = performance.now(); +} diff --git a/src/vanilla/types/event.ts b/src/vanilla/types/event.ts new file mode 100644 index 0000000..c2f0bfd --- /dev/null +++ b/src/vanilla/types/event.ts @@ -0,0 +1,184 @@ +import type { Atom } from 'jotai'; + +/** + * String representation of an atom. + * It is the result of calling `atom.toString()`. + */ +export type AtomId = string; + +/** + * Generic atom type. + */ +export type AnyAtom = Atom; + +export const AtomEventTypes = { + initialized: 1, + initialPromisePending: 2, + initialPromiseResolved: 3, + initialPromiseRejected: 4, + initialPromiseAborted: 5, + changed: 6, + changedPromisePending: 7, + changedPromiseResolved: 8, + changedPromiseRejected: 9, + changedPromiseAborted: 10, + dependenciesChanged: 11, + mounted: 12, + unmounted: 13, + destroyed: 14, +} as const; + +export type AtomEventTypes = typeof AtomEventTypes; + +export type AtomEventType = AtomEventTypes[keyof AtomEventTypes]; + +/** + * Fields common to all event types. + */ +export interface AtomEventBase { + /** Atoms whose pending promises are blocking this atom at the time of the event. @see {@link AtomState.p} */ + pendingPromises?: Set; + /** The set of atoms this atom depends on at the time of the event. @see {@link AtomState.d} @see {@link Mounted.d} */ + dependencies?: Set; + /** The set of atoms that depend on this atom at the time of the event. @see {@link Mounted.t} */ + dependents?: Set; +} + +/** Event emitted when an atom is initialized with a synchronous value. */ +export interface AtomEventInitialized extends AtomEventBase { + type: AtomEventTypes['initialized']; + atom: AnyAtom; + /** The initial value of the atom. */ + value: unknown; +} + +/** Event emitted when an atom's initial promise is pending. */ +export interface AtomEventInitialPromisePending extends AtomEventBase { + type: AtomEventTypes['initialPromisePending']; + atom: AnyAtom; +} + +/** Event emitted when an atom's initial promise resolves. */ +export interface AtomEventInitialPromiseResolved extends AtomEventBase { + type: AtomEventTypes['initialPromiseResolved']; + atom: AnyAtom; + /** The resolved value of the promise. */ + value: unknown; +} + +/** Event emitted when an atom's initial promise rejects. */ +export interface AtomEventInitialPromiseRejected extends AtomEventBase { + type: AtomEventTypes['initialPromiseRejected']; + atom: AnyAtom; + /** The rejection reason. */ + error: unknown; +} + +/** Event emitted when an atom's initial promise is aborted. */ +export interface AtomEventInitialPromiseAborted extends AtomEventBase { + type: AtomEventTypes['initialPromiseAborted']; + atom: AnyAtom; +} + +/** Event emitted when an atom's value changes. */ +export interface AtomEventChanged extends AtomEventBase { + type: AtomEventTypes['changed']; + atom: AnyAtom; + /** The previous value of the atom. */ + oldValue: unknown; + /** The new value of the atom. */ + newValue: unknown; +} + +/** Event emitted when an atom changes to a pending promise. */ +export interface AtomEventChangedPromisePending extends AtomEventBase { + type: AtomEventTypes['changedPromisePending']; + atom: AnyAtom; + /** The previous synchronous value before the promise became pending. */ + oldValue: unknown; +} + +/** Event emitted when an atom's changed promise resolves. */ +export interface AtomEventChangedPromiseResolved extends AtomEventBase { + type: AtomEventTypes['changedPromiseResolved']; + atom: AnyAtom; + /** The value before the promise was pending. */ + oldValue: unknown; + /** The resolved value of the promise. */ + newValue: unknown; +} + +/** Event emitted when an atom's changed promise rejects. */ +export interface AtomEventChangedPromiseRejected extends AtomEventBase { + type: AtomEventTypes['changedPromiseRejected']; + atom: AnyAtom; + /** The value before the promise was pending. */ + oldValue: unknown; + /** The rejection reason. */ + error: unknown; +} + +/** Event emitted when an atom's changed promise is aborted. */ +export interface AtomEventChangedPromiseAborted extends AtomEventBase { + type: AtomEventTypes['changedPromiseAborted']; + atom: AnyAtom; + /** The value before the promise was pending. */ + oldValue: unknown; +} + +/** Event emitted when an atom's dependencies change. */ +export interface AtomEventDependenciesChanged extends AtomEventBase { + type: AtomEventTypes['dependenciesChanged']; + atom: AnyAtom; + /** The set of dependencies before this transaction. Absent when previously empty. */ + oldDependencies?: Set; + /** Dependencies added during this transaction. Absent when none were added. */ + addedDependencies?: Set; + /** Dependencies removed during this transaction. Absent when none were removed. */ + removedDependencies?: Set; +} + +/** Event emitted when an atom is mounted (subscribed to). */ +export interface AtomEventMounted extends AtomEventBase { + type: AtomEventTypes['mounted']; + atom: AnyAtom; + /** The atom's value at mount time, if already initialized. */ + value?: unknown; +} + +/** Event emitted when an atom is unmounted (no more subscribers). */ +export interface AtomEventUnmounted extends AtomEventBase { + type: AtomEventTypes['unmounted']; + atom: AnyAtom; +} + +/** Event emitted when an atom is garbage collected. */ +export interface AtomEventDestroyed extends AtomEventBase { + type: AtomEventTypes['destroyed']; + /** Only the {@link AtomId} string is available because the atom object has been garbage collected. */ + atom: AtomId; +} + +/** + * Map from event type number to its concrete event shape. + * Used for discriminated union lookup. + */ +export interface AtomEventMap { + [AtomEventTypes.initialized]: AtomEventInitialized; + [AtomEventTypes.initialPromisePending]: AtomEventInitialPromisePending; + [AtomEventTypes.initialPromiseResolved]: AtomEventInitialPromiseResolved; + [AtomEventTypes.initialPromiseRejected]: AtomEventInitialPromiseRejected; + [AtomEventTypes.initialPromiseAborted]: AtomEventInitialPromiseAborted; + [AtomEventTypes.changed]: AtomEventChanged; + [AtomEventTypes.changedPromisePending]: AtomEventChangedPromisePending; + [AtomEventTypes.changedPromiseResolved]: AtomEventChangedPromiseResolved; + [AtomEventTypes.changedPromiseRejected]: AtomEventChangedPromiseRejected; + [AtomEventTypes.changedPromiseAborted]: AtomEventChangedPromiseAborted; + [AtomEventTypes.dependenciesChanged]: AtomEventDependenciesChanged; + [AtomEventTypes.mounted]: AtomEventMounted; + [AtomEventTypes.unmounted]: AtomEventUnmounted; + [AtomEventTypes.destroyed]: AtomEventDestroyed; +} + +/** Union of all concrete event types. */ +export type AtomEvent = AtomEventMap[keyof AtomEventMap]; diff --git a/src/vanilla/types/formatter.ts b/src/vanilla/types/formatter.ts new file mode 100644 index 0000000..8c03215 --- /dev/null +++ b/src/vanilla/types/formatter.ts @@ -0,0 +1,6 @@ +import type { AtomTransaction } from './transaction.js'; + +/** + * A formatter that receives a completed transaction and produces output (e.g. logs to console). + */ +export type AtomLoggerFormatter = (transaction: AtomTransaction) => void; diff --git a/src/vanilla/types/options.ts b/src/vanilla/types/options.ts new file mode 100644 index 0000000..2516a17 --- /dev/null +++ b/src/vanilla/types/options.ts @@ -0,0 +1,228 @@ +import type { Atom } from 'jotai'; + +import type { AtomLoggerFormatter } from './formatter.js'; + +/** + * Options for the atoms logger. + * + * These control event collection and transaction scheduling only. + * To customise the console output, pass a {@link AtomLoggerFormatter} via the `formatter` option, + * or use {@link consoleFormatter} from `jotai-logger/formatters/console`. + */ +export interface AtomLoggerOptions { + /** + * Custom formatter called for each completed transaction. + * + * When not provided, a default `consoleFormatter()` (from `jotai-logger/formatters/console`) + * is used with its default options. + * + * @example + * ```ts + * import { consoleFormatter } from 'jotai-logger/formatters/console'; + * const store = createLoggedStore(parentStore, { + * formatter: consoleFormatter({ colorScheme: 'dark', domain: 'MyApp' }), + * }); + * ``` + */ + formatter?: AtomLoggerFormatter; + + /** + * Enable or disable the logger. + * + * @default true + */ + enabled?: boolean; + + /** + * Whether to show private atoms in the console. + * + * Private are atoms that are used by Jotai libraries internally to manage state. + * They're often used internally in atoms like `atomWithStorage` or `atomWithLocation`, etc. to manage state. + * They are determined by the `debugPrivate` property of the atom. + * + * @default false + */ + shouldShowPrivateAtoms?: boolean; + + /** + * Function to determine whether to show a specific atom in the console. + * + * This is useful for filtering out atoms that you don't want to see in the console. + * + * `shouldShowPrivateAtoms` takes precedence over this option. + * + * @example + * ```ts + * // Show all atoms that have a debug label + * const shouldShowAtom = (atom: Atom) => atom.debugLabel !== undefined; + * createLoggedStore(parentStore, { shouldShowAtom }); + * + * // Don't show a specific atom + * const verboseAtom = atom(0); + * const shouldShowAtom = (atom: Atom) => atom !== verboseAtom; + * createLoggedStore(parentStore, { shouldShowAtom }); + * + * // Dont show an atom with a specific property + * const verboseAtom = atom(0); + * verboseAtom.debugLabel = 'verbose'; + * Object.assign(verboseAtom, { canLog: false }); + * const shouldShowAtom = (atom: Atom) => !('canLog' in atom) || atom.canLog === true; + * createLoggedStore(parentStore, { shouldShowAtom }); + * ``` + */ + shouldShowAtom?(this: void, atom: Atom): boolean; + + /** + * **Experimental feature** - Get the React component owner stack. + * + * This function should return a stack trace string showing the React component hierarchy + * that triggered the current transaction. The logger will parse this to show up to + * `ownerStackLimit` parent components in the logs. + * + * **React 19.1+ Example:** + * ```tsx + * import { captureOwnerStack } from 'react'; + * + * createLoggedStore(parentStore, { getOwnerStack: captureOwnerStack }); + * ``` + * + * **Expected format:** + * ``` + * at MiddleWrapper (http://localhost:5173/src/App.tsx:70:21) + * at ParentContainer (http://localhost:5173/src/App.tsx:31:21) + * at App (http://localhost:5173/src/App.tsx:108:21) + * ``` + * + * **Output example:** + * ``` + * transaction 1 : [ParentContainer.MiddleWrapper] retrieved value of countAtom + * ``` + * + * @returns Stack trace string, null, or undefined + */ + getOwnerStack?(this: void): string | null | undefined; + + /** + * **Experimental feature** - Get the current React component's display name. + * + * This function should return the display name or name of the currently rendering + * React component. The logger will show this in transaction logs to help identify + * which component triggered the state change. + * + * **React 19+ Example:** + * ```tsx + * import React from 'react'; + * + * function getReact19ComponentDisplayName(): string | undefined { + * const React19 = React as any; + * const component = ( + * React19.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ?? + * React19.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + * )?.A?.getOwner?.().type; + * return component?.displayName ?? component?.name; + * } + * + * createLoggedStore(parentStore, { + * getComponentDisplayName: getReact19ComponentDisplayName + * }); + * ``` + * + * **Output example:** + * ``` + * transaction 1 : MyCounter retrieved value of countAtom + * ``` + * + * **Note:** When used with `getOwnerStack`, the component display name will only + * be shown if it's different from the last component shown in the owner stack. + * + * @returns Component display name or undefined + */ + getComponentDisplayName?(this: void): string | undefined; + + /** + * Whether to log transactions synchronously or asynchronously. + * + * - If set to `true`, the logger will log transactions synchronously + * - This makes `transactionDebounceMs`, `requestIdleCallbackTimeoutMs` + * and `maxProcessingTimeMs` options irrelevant. + * - This is useful for debugging purposes or if you want to see the logs + * immediately. + * - If set to `false`, the logger will log transactions asynchronously + * - First, transaction events are debounced using `transactionDebounceMs` + * option. + * - Then, the transactions are scheduled to be logged using + * `requestIdleCallback` with a maximum timeout defined by + * `requestIdleCallbackTimeoutMs` option. + * - Finally, the transactions are processed in chunks with a maximum + * processing time defined by `maxProcessingTimeMs` option. + * - This is useful for reducing the impact of the logger on the application + * performance. + * + * @default false + */ + synchronous?: boolean; + + /** + * Debounce time for transaction flushing in milliseconds. + * + * Only used if `synchronous` is set to `false`. + * + * - This ensures that multiple independent events are logged together in a + * single transaction instead of multiple transactions. + * + * - Use `0` for no debounce, which means that every transaction will be + * scheduled to be logged immediately. This is the same as setting + * `synchronous` to `true`. + * + * @default 250 + */ + transactionDebounceMs?: number; + + /** + * Timeout in milliseconds for the + * {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#timeout | `requestIdleCallback`} + * used to flush transactions. + * + * Only used if `synchronous` is set to `false`. + * + * - `requestIdleCallback` queues transactions to be logged during a browser's + * idle periods with this maximum timeout per group of transactions. + * This ensure that the logger does not impact too much the application performances. + * + * - Use a positive value to set a maximum timeout for the + * `requestIdleCallback`. + * - This means that the logger will wait for the browser to be idle for at + * least this amount of time before logging the queued transactions. + * - It will fallback to `setTimeout` with a timeout of `0` if the browser + * does not support `requestIdleCallback`. + * + * - Use `0` to wait indefinitely for the browser to be idle before logging + * the queued transactions. + * - It will fallback to `setTimeout` with a timeout of `0` if the browser + * does not support `requestIdleCallback`. + * + * - Use `-1` or lower to log scheduled transactions immediately. This is the + * same as setting `synchronous` to `true`. + * + * @default 250 + */ + requestIdleCallbackTimeoutMs?: number; + + /** + * Maximum number of milliseconds to process transactions in one go. + * + * Only used if `synchronous` is set to `false`. + * + * - This is to avoid blocking the main thread for too long. + * - If there are still transactions in the queue after this time, they will be + * scheduled to be processed in the next idle period. + * + * - Use a positive value to set the maximum processing time per idle period. + * - Use `0` or lower to process all queued transactions in one go, which may block + * the main thread for a long time. + * This is the same as setting `synchronous` to `true`. + * + * @default 16 (approximately 1 frame at 60fps) + */ + maxProcessingTimeMs?: number; +} diff --git a/src/vanilla/types/store.ts b/src/vanilla/types/store.ts new file mode 100644 index 0000000..ddf2438 --- /dev/null +++ b/src/vanilla/types/store.ts @@ -0,0 +1,45 @@ +import type { useStore } from 'jotai'; + +import type { AnyAtom, AtomId } from './event.js'; +import type { AtomLoggerOptions } from './options.js'; +import type { AtomTransaction } from './transaction.js'; + +/** + * Jotai's store. + */ +export type Store = ReturnType; + +/** + * Internal state of the logger. + */ +export interface AtomLoggerStoreState { + /** The logger's options. Can be mutated at runtime to change logger behavior. */ + options: AtomLoggerOptions; + /** Incremental counter for transactions */ + transactionNumber: number; + /** The currently active transaction being tracked, if any */ + currentTransaction: AtomTransaction | undefined; + /** Flag to indicate if the logger is currently processing a transaction (not debouncing) */ + isInsideTransaction: boolean; + /** FinalizationRegistry that register atoms garbage collection */ + atomsFinalizationRegistry: FinalizationRegistry; + /** Map to track the values of promises */ + promisesResultsMap: WeakMap, unknown>; + /** Map to track the previous dependencies of atoms since last transaction */ + prevTransactionDependenciesMap: WeakMap>; + /** Map to track the dependencies of atoms */ + dependenciesMap: WeakMap>; + /** Timeout id of the current transaction if started independently (not triggered by a store update) */ + transactionsDebounceTimeoutId: ReturnType | undefined; + /** Scheduler for logging queued transactions */ + logTransactionsScheduler: { + /** Queue of transactions to be logged */ + queue: AtomTransaction[]; + /** Flag to indicate if the scheduler is currently processing */ + isProcessing: boolean; + /** Process the next transaction in the queue */ + process: () => void; + /** Add a transaction to the queue and process it */ + add: (transaction: AtomTransaction) => void; + }; +} diff --git a/src/vanilla/types/transaction.ts b/src/vanilla/types/transaction.ts new file mode 100644 index 0000000..ba80d12 --- /dev/null +++ b/src/vanilla/types/transaction.ts @@ -0,0 +1,100 @@ +import type { AnyAtom, AtomId, AtomEvent } from './event.js'; + +export const AtomTransactionTypes = { + unknown: 1, + storeGet: 2, + storeSet: 3, + storeSubscribe: 4, + storeUnsubscribe: 5, + promiseResolved: 6, + promiseRejected: 7, +} as const; + +export type AtomTransactionTypes = typeof AtomTransactionTypes; + +export type AtomTransactionType = AtomTransactionTypes[keyof AtomTransactionTypes]; + +/** + * Fields common to all transaction types. + */ +export interface AtomTransactionBase { + /** + * The atom that triggered the transaction. + * - Is the atom object itself for most events. + * - Is an {@link AtomId} string for `destroyed` events since the atom object has been garbage collected by the time the event is emitted. + * - Is `undefined` for events related to private atoms or ignored atoms (with `shouldShowAtom` returning false) to avoid exposing them to the logger. + */ + atom: AnyAtom | AtomId | undefined; + /** Monotonically increasing counter identifying this transaction. */ + transactionNumber: number; + /** React owner stack captured at the time the transaction started (experimental). */ + ownerStack?: string | null | undefined; + /** Display name of the React component that triggered the transaction (experimental). */ + componentDisplayName?: string | undefined; + /** Ordered list of events recorded during this transaction. */ + events: AtomEvent[]; + /** `performance.now()` timestamp when the transaction started. */ + startTimestamp: ReturnType; + /** `performance.now()` timestamp when the transaction ended. */ + endTimestamp: ReturnType; +} + +/** Transaction produced when the origin of the state change cannot be determined. */ +export interface AtomTransactionUnknown extends AtomTransactionBase { + type: AtomTransactionTypes['unknown']; +} + +/** Transaction produced by a `store.get` call. */ +export interface AtomTransactionStoreGet extends AtomTransactionBase { + type: AtomTransactionTypes['storeGet']; +} + +/** Transaction produced by a `store.set` call. */ +export interface AtomTransactionStoreSet extends AtomTransactionBase { + type: AtomTransactionTypes['storeSet']; + /** Arguments passed to `store.set`. */ + args: unknown[]; + /** Return value of the atom's write function, if any. */ + result: unknown; +} + +/** Transaction produced by a `store.sub` call (subscription started). */ +export interface AtomTransactionStoreSubscribe extends AtomTransactionBase { + type: AtomTransactionTypes['storeSubscribe']; + /** The listener function registered with `store.sub`. */ + listener: () => void; +} + +/** Transaction produced when a subscription created by `store.sub` is unsubscribed. */ +export interface AtomTransactionStoreUnsubscribe extends AtomTransactionBase { + type: AtomTransactionTypes['storeUnsubscribe']; + /** The listener function that was unsubscribed. */ + listener: () => void; +} + +/** Transaction produced when a pending promise atom resolves. */ +export interface AtomTransactionPromiseResolved extends AtomTransactionBase { + type: AtomTransactionTypes['promiseResolved']; +} + +/** Transaction produced when a pending promise atom rejects. */ +export interface AtomTransactionPromiseRejected extends AtomTransactionBase { + type: AtomTransactionTypes['promiseRejected']; +} + +/** + * Map from transaction type number to its concrete transaction shape. + * Used for discriminated union lookup. + */ +export interface AtomTransactionMap { + [AtomTransactionTypes.unknown]: AtomTransactionUnknown; + [AtomTransactionTypes.storeGet]: AtomTransactionStoreGet; + [AtomTransactionTypes.storeSet]: AtomTransactionStoreSet; + [AtomTransactionTypes.storeSubscribe]: AtomTransactionStoreSubscribe; + [AtomTransactionTypes.storeUnsubscribe]: AtomTransactionStoreUnsubscribe; + [AtomTransactionTypes.promiseResolved]: AtomTransactionPromiseResolved; + [AtomTransactionTypes.promiseRejected]: AtomTransactionPromiseRejected; +} + +/** Union of all concrete transaction types. */ +export type AtomTransaction = AtomTransactionMap[keyof AtomTransactionMap]; diff --git a/src/vanilla/utils/filter-atoms.ts b/src/vanilla/utils/filter-atoms.ts new file mode 100644 index 0000000..6133d7e --- /dev/null +++ b/src/vanilla/utils/filter-atoms.ts @@ -0,0 +1,19 @@ +import type { AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; +import { shouldShowAtom } from './should-show-atom.js'; + +export function filterAtoms( + atoms: Set | undefined, + loggerState: AtomLoggerStoreState, +): Set | undefined { + if (!atoms) { + return undefined; + } + const result = new Set(); + for (const atom of atoms) { + if (shouldShowAtom(loggerState, atom)) { + result.add(atom); + } + } + return result; +} diff --git a/src/vanilla/utils/get-atom-value.ts b/src/vanilla/utils/get-atom-value.ts new file mode 100644 index 0000000..475f5ed --- /dev/null +++ b/src/vanilla/utils/get-atom-value.ts @@ -0,0 +1,23 @@ +import { INTERNAL_isPromiseLike as isPromiseLike } from 'jotai/vanilla/internals'; +import type { INTERNAL_BuildingBlocks as BuildingBlocks } from 'jotai/vanilla/internals'; + +import type { AnyAtom } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function getAtomValue( + loggerState: AtomLoggerStoreState, + buildingBlocks: Readonly, + atom: AnyAtom, +): { hasValue: boolean; value?: unknown } { + const parentAtomStateMap = buildingBlocks[0]; + const state = parentAtomStateMap.get(atom); + const value = state?.v; + if (isPromiseLike(value)) { + if (!loggerState.promisesResultsMap.has(value)) { + return { hasValue: false }; + } + const promiseValue = loggerState.promisesResultsMap.get(value); + return { hasValue: true, value: promiseValue }; + } + return { hasValue: true, value }; +} diff --git a/src/vanilla/utils/should-set-state-in-event.ts b/src/vanilla/utils/should-set-state-in-event.ts new file mode 100644 index 0000000..ba1e0f1 --- /dev/null +++ b/src/vanilla/utils/should-set-state-in-event.ts @@ -0,0 +1,10 @@ +import { AtomEventTypes, type AtomEvent } from '../types/event.js'; + +/** + * Returns `true` if the event type carries state data (value, dependencies, etc.). + * + * `unmounted` and `destroyed` events do not carry any additional state. + */ +export function shouldSetStateInEvent(event: AtomEvent): boolean { + return event.type !== AtomEventTypes.unmounted && event.type !== AtomEventTypes.destroyed; +} diff --git a/src/vanilla/utils/should-show-atom.ts b/src/vanilla/utils/should-show-atom.ts new file mode 100644 index 0000000..8a9ef79 --- /dev/null +++ b/src/vanilla/utils/should-show-atom.ts @@ -0,0 +1,18 @@ +import type { AnyAtom, AtomId } from '../types/event.js'; +import type { AtomLoggerStoreState } from '../types/store.js'; + +export function shouldShowAtom(loggerState: AtomLoggerStoreState, atom: AnyAtom | AtomId): boolean { + if (!loggerState.options.enabled) { + return false; + } + if (typeof atom === 'string') { + return true; + } + if (!loggerState.options.shouldShowPrivateAtoms && atom.debugPrivate === true) { + return false; + } + if (loggerState.options.shouldShowAtom) { + return loggerState.options.shouldShowAtom(atom); + } + return true; +} diff --git a/tests/atom-logger-changes.test.ts b/tests/atom-logger-changes.test.ts new file mode 100644 index 0000000..c4486ad --- /dev/null +++ b/tests/atom-logger-changes.test.ts @@ -0,0 +1,353 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('changes', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log atom value changes', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + + // value + store.get(testAtom); + + // old value -> new value + store.set(testAtom, 43); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + + [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], + [`changed value of ${testAtom} from 42 to 43`, { newValue: 43, oldValue: 42 }], + ]); + }); + + it('should log atom value and promise changes', async () => { + store = createLoggedStore(store, defaultOptions); + + const valueTypeAtom = atom<'value' | 'resolve' | 'reject' | 'reject2'>('resolve'); + valueTypeAtom.debugPrivate = true; + + let count = 0; + const promiseAtom = atom>(async (get) => { + count += 1; + const type = get(valueTypeAtom); + if (type === 'value') { + return count; + } else if (type === 'reject' || type === 'reject2') { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`${count}`)); + }, 100); + }); + } else { + return new Promise((resolve) => { + setTimeout(() => { + resolve(count); + }, 100); + }); + } + }); + + // value + store.set(valueTypeAtom, 'value'); + store.sub(promiseAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + + // value -> promise resolve + store.set(valueTypeAtom, 'resolve'); + await vi.advanceTimersByTimeAsync(250); + + // promise resolve -> promise reject + store.set(valueTypeAtom, 'reject'); + await vi.advanceTimersByTimeAsync(250); + + // promise reject -> promise resolve + store.set(valueTypeAtom, 'resolve'); + await vi.advanceTimersByTimeAsync(250); + + // promise resolve -> value + store.set(valueTypeAtom, 'value'); + await vi.advanceTimersByTimeAsync(250); + + // value -> promise reject + store.set(valueTypeAtom, 'reject'); + await vi.advanceTimersByTimeAsync(250); + + // promise reject -> promise reject + store.set(valueTypeAtom, 'reject2'); + await vi.advanceTimersByTimeAsync(250); + + // promise reject -> value + store.set(valueTypeAtom, 'value'); + await vi.advanceTimersByTimeAsync(250); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + // value + [`transaction 1 : subscribed to ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [`mounted ${promiseAtom}`], + [`resolved initial promise of ${promiseAtom} to 1`, { value: 1 }], + + // value -> promise resolve + ['transaction 2'], + [`pending promise of ${promiseAtom} from 1`, { oldValue: 1 }], + [`resolved promise of ${promiseAtom} from 1 to 2`, { oldValue: 1, newValue: 2 }], + + // promise resolve -> promise reject + ['transaction 3'], + [`pending promise of ${promiseAtom} from 2`, { oldValue: 2 }], + [ + `rejected promise of ${promiseAtom} from 2 to Error: 3`, + { oldValue: 2, error: new Error('3') }, + ], + + // promise reject -> promise resolve + ['transaction 4'], + [`pending promise of ${promiseAtom} from Error: 3`, { oldError: new Error('3') }], + [ + `resolved promise of ${promiseAtom} from Error: 3 to 4`, + { oldError: new Error('3'), value: 4 }, + ], + + // promise resolve -> value + ['transaction 5'], + [`pending promise of ${promiseAtom} from 4`, { oldValue: 4 }], + [`resolved promise of ${promiseAtom} from 4 to 5`, { newValue: 5, oldValue: 4 }], + + // value -> promise reject + ['transaction 6'], + [`pending promise of ${promiseAtom} from 5`, { oldValue: 5 }], + [ + `rejected promise of ${promiseAtom} from 5 to Error: 6`, + { oldValue: 5, error: new Error('6') }, + ], + + // promise reject -> promise reject + ['transaction 7'], + [`pending promise of ${promiseAtom} from Error: 6`, { oldError: new Error('6') }], + [ + `rejected promise of ${promiseAtom} from Error: 6 to Error: 7`, + { oldError: new Error('6'), newError: new Error('7') }, + ], + + // promise reject -> value + ['transaction 8'], + [`pending promise of ${promiseAtom} from Error: 7`, { oldError: new Error('7') }], + [ + `resolved promise of ${promiseAtom} from Error: 7 to 8`, + { oldError: new Error('7'), value: 8 }, + ], + ]); + }); + + it('should merge atom value changes if they are in the same transaction', () => { + store = createLoggedStore(store, defaultOptions); + + const valueAtom = atom(0); + + const valueSetAtom = atom(null, (get, set) => { + set(valueAtom, 1); + set(valueAtom, 2); + set(valueAtom, 3); + set(valueAtom, 4); + set(valueAtom, 5); + }); + + store.set(valueSetAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : called set of ${valueSetAtom}`], + [`initialized value of ${valueAtom} to 1`, { value: 1 }], + [ + `changed value of ${valueAtom} 4 times from 1 to 5`, + { oldValues: [1, 2, 3, 4], newValue: 5 }, + ], + ]); + }); + + it('should log merged atom value changes as is', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: false, + stringifyValues: false, + }), + }); + + const valueAtom = atom(0); + + const valueSetAtom = atom(null, (get, set) => { + set(valueAtom, 1); + set(valueAtom, 2); + set(valueAtom, 3); + set(valueAtom, 4); + set(valueAtom, 5); + }); + + store.set(valueSetAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : called set of ${valueSetAtom}`], + [`initialized value of ${valueAtom} to`, 1], + [`changed value of ${valueAtom} 4 times from`, [1, 2, 3, 4], `to`, 5], + ]); + }); + + it('should log merged atom value changes in colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const valueAtom = atom(0); + + const valueSetAtom = atom(null, (get, set) => { + set(valueAtom, 1); + set(valueAtom, 2); + set(valueAtom, 3); + set(valueAtom, 4); + set(valueAtom, 5); + }); + + const valueAtomNumber = /atom(\d+)(.*)/.exec(valueAtom.toString())?.[1]; + const valueSetAtomNumber = /atom(\d+)(.*)/.exec(valueSetAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(valueAtomNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(valueSetAtomNumber!))).toBeTruthy(); + + store.set(valueSetAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %ccalled set %cof %catom%c${valueSetAtomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #E69F00; font-weight: bold;', // called set + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${valueAtomNumber} %cto %c1`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 1 + { value: 1 }, + ], + [ + `%cchanged value %cof %catom%c${valueAtomNumber} %c4 %ctimes %cfrom %c1 %cto %c5`, + 'color: #56B4E9; font-weight: bold;', // changed value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: default; font-weight: normal;', // 4 + 'color: #757575; font-weight: normal;', // times + 'color: #757575; font-weight: normal;', // from + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 5 + { newValue: 5, oldValues: [1, 2, 3, 4] }, + ], + ]); + }); + + it('should not crash when logging an atom with a circular value', () => { + store = createLoggedStore(store, defaultOptions); + + const circularValue = {} as { self: unknown }; + circularValue.self = circularValue; + const circularAtom = atom(circularValue); + + store.get(circularAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${circularAtom}`], + [`initialized value of ${circularAtom} to [Circular]`, { value: circularValue }], + ]); + }); +}); diff --git a/tests/atom-logger-colors.test.ts b/tests/atom-logger-colors.test.ts new file mode 100644 index 0000000..db442cb --- /dev/null +++ b/tests/atom-logger-colors.test.ts @@ -0,0 +1,404 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('colors', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should not log colors if formattedOutput is false', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: false }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should log colors if formattedOutput is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const testAtom = atom(0); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); + + it('should log atom name without namespaces with color', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const testAtom = atom(0); + testAtom.debugLabel = 'testAtomWithoutNamespaces'; + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}%c:%ctestAtomWithoutNamespaces`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: default; font-weight: normal;', // testAtomWithoutNamespaces + ], + [ + `%cinitialized value %cof %catom%c${atomNumber}%c:%ctestAtomWithoutNamespaces %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: default; font-weight: normal;', // testAtomWithoutNamespaces + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); + + it('should log atom name namespaces with colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const testAtom = atom(0); + testAtom.debugLabel = 'test/atom/with/namespaces'; + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}%c:%ctest%c/%catom%c/%cwith%c/namespaces`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #757575; font-weight: normal;', // test + 'color: default; font-weight: normal;', // / + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // / + 'color: #757575; font-weight: normal;', // with + 'color: default; font-weight: normal;', // /namespaces + ], + [ + `%cinitialized value %cof %catom%c${atomNumber}%c:%ctest%c/%catom%c/%cwith%c/namespaces %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #757575; font-weight: normal;', // test + 'color: default; font-weight: normal;', // / + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // / + 'color: #757575; font-weight: normal;', // with + 'color: default; font-weight: normal;', // /namespaces + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); + + it('should log dark colors with dark colorScheme option', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + colorScheme: 'dark', + }), + }); + + const testAtom = atom(0); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #999999; font-weight: normal;', + 'color: default; font-weight: normal;', + 'color: #999999; font-weight: normal;', + 'color: #009EFA; font-weight: bold;', + 'color: #999999; font-weight: normal;', + 'color: #999999; font-weight: normal;', + 'color: default; font-weight: normal;', + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #009EFA; font-weight: bold;', + 'color: #999999; font-weight: normal;', + 'color: #999999; font-weight: normal;', + 'color: default; font-weight: normal;', + 'color: #999999; font-weight: normal;', + 'color: default; font-weight: normal;', + { value: 0 }, + ], + ]); + }); + + it('should log light colors with light colorScheme option', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + colorScheme: 'light', + }), + }); + + const testAtom = atom(0); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #6E6E6E; font-weight: normal;', + 'color: default; font-weight: normal;', + 'color: #6E6E6E; font-weight: normal;', + 'color: #0072B2; font-weight: bold;', + 'color: #6E6E6E; font-weight: normal;', + 'color: #6E6E6E; font-weight: normal;', + 'color: default; font-weight: normal;', + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', + 'color: #6E6E6E; font-weight: normal;', + 'color: #6E6E6E; font-weight: normal;', + 'color: default; font-weight: normal;', + 'color: #6E6E6E; font-weight: normal;', + 'color: default; font-weight: normal;', + { value: 0 }, + ], + ]); + }); + + it('should log colored stack traces', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + getOwnerStack() { + return `at MyComponentParent (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; + }, + getComponentDisplayName() { + return 'MyComponent'; + }, + }); + + const testAtom = atom(0); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %c[MyComponentParent] %cMyComponent %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #757575; font-weight: normal;', // [MyComponentParent] + 'color: default; font-weight: normal;', // MyComponent + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); + + it('should log colored stack traces with hooks', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + getOwnerStack() { + return `at ParentContainer (http://localhost:5173/src/App.tsx?t=1757750948197:31:21) + at App (http://localhost:5173/src/App.tsx?t=1757750948197:108:21)`; + }, + getComponentDisplayName() { + return 'MyComponent'; + }, + }); + + const testAtom = atom(0); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %c[App.ParentContainer] %cMyComponent %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #757575; font-weight: normal;', // [App.ParentContainer] + 'color: default; font-weight: normal;', // MyComponent + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-complex-graphs.test.ts b/tests/atom-logger-complex-graphs.test.ts new file mode 100644 index 0000000..ebc3e82 --- /dev/null +++ b/tests/atom-logger-complex-graphs.test.ts @@ -0,0 +1,247 @@ +import { atom } from 'jotai'; +import { loadable } from 'jotai-loadable'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('complex graphs', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log async atoms with dependencies and dependents', async () => { + store = createLoggedStore(store, defaultOptions); + + const firstAtom = atom('first'); + const secondAtom = atom('second'); + const thirdAsyncAtom = atom>(async (get) => { + const third = get(firstAtom) + ' ' + 'third'; + return new Promise((resolve) => { + setTimeout(() => { + resolve(third); + }, 500); + }); + }); + + // loadable uses unwrap internally and since loadable doesn't expose it, we use a regex to match it + const unwrappedThirdAsyncAtomDebugLabelRegex = new RegExp(`atom\\d+`); + + const resultAtom = atom((get) => { + const second = get(secondAtom); + const third = get(loadable(thirdAsyncAtom)); + return `${second} ${third.state === 'hasData' ? third.data : third.state}`; + }); + + store.sub(resultAtom, vi.fn()); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${resultAtom}`], + + // result <-- second + [ + `initialized value of ${secondAtom} to "second"`, + { + value: 'second', + dependents: [`${resultAtom}`], + }, + ], + // result <-- loadable(thirdAsync) <-- thirdAsync <-- first + [ + `initialized value of ${firstAtom} to "first"`, + { + value: 'first', + dependents: [`${thirdAsyncAtom}`], + pendingPromises: [`${thirdAsyncAtom}`], + }, + ], + // result <-- loadable(thirdAsync) <-- thirdAsync + [ + `pending initial promise of ${thirdAsyncAtom}`, + { + dependencies: [`${firstAtom}`], + }, + ], + // result <-- loadable(thirdAsync) + [ + expect.stringMatching( + new RegExp( + `initialized value of ${unwrappedThirdAsyncAtomDebugLabelRegex.source} to {"state":"loading"}`, + ), + ), + { + value: { state: 'loading' }, + dependents: [`${loadable(thirdAsyncAtom)}`], + }, + ], + [ + `initialized value of ${loadable(thirdAsyncAtom)} to {"state":"loading"}`, + { + value: { state: 'loading' }, + dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], + dependents: [`${resultAtom}`], + }, + ], + // result + [ + `initialized value of ${resultAtom} to "second loading"`, + { + value: 'second loading', + dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], + }, + ], + [ + `mounted ${secondAtom}`, + { + value: 'second', + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${firstAtom}`, + { + value: 'first', + dependents: [`${thirdAsyncAtom}`], + pendingPromises: [`${thirdAsyncAtom}`], + }, + ], + [ + `mounted ${thirdAsyncAtom}`, + { + dependencies: [`${firstAtom}`], + }, + ], + [ + expect.stringMatching( + new RegExp(`mounted ${unwrappedThirdAsyncAtomDebugLabelRegex.source}`), + ), + { + value: { state: 'loading' }, + dependents: [`${loadable(thirdAsyncAtom)}`], + }, + ], + [ + `mounted ${loadable(thirdAsyncAtom)}`, + { + value: { state: 'loading' }, + dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${resultAtom}`, + { + value: 'second loading', + dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], + }, + ], + + [`transaction 2 : resolved promise of ${thirdAsyncAtom}`], + // result <-- loadable(thirdAsync) <-- thirdAsync <-- promise resolved + [ + `resolved initial promise of ${thirdAsyncAtom} to "first third"`, + { + value: 'first third', + dependencies: [`${firstAtom}`], + }, + ], + ['transaction 3'], + [ + expect.stringMatching( + new RegExp( + `changed value of ${unwrappedThirdAsyncAtomDebugLabelRegex.source} from {"state":"loading"} to "first third"`, + ), + ), + { + newValue: 'first third', + oldValue: { state: 'loading' }, + dependents: [`${loadable(thirdAsyncAtom)}`], + }, + ], + // result <-- loadable(thirdAsync) + [ + `changed value of ${loadable(thirdAsyncAtom)} from {"state":"loading"} to {"state":"hasData","data":"first third"}`, + { + newValue: { data: 'first third', state: 'hasData' }, + oldValue: { state: 'loading' }, + dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], + dependents: [`${resultAtom}`], + }, + ], + // result + [ + `changed value of ${resultAtom} from "second loading" to "second first third"`, + { + newValue: 'second first third', + oldValue: 'second loading', + dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], + }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-debug-label.test.ts b/tests/atom-logger-debug-label.test.ts new file mode 100644 index 0000000..89e2cbf --- /dev/null +++ b/tests/atom-logger-debug-label.test.ts @@ -0,0 +1,162 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('debugLabel', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log atoms without debug labels', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of atom${atomNumber}`], + [`initialized value of atom${atomNumber} to 42`, { value: 42 }], + ]); + }); + + it('should log atoms with debug labels', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + testAtom.debugLabel = 'Test Atom'; + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of atom${atomNumber}:Test Atom`], + [`initialized value of atom${atomNumber}:Test Atom to 42`, { value: 42 }], + ]); + }); + + it('should log atoms with a custom toString method', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + testAtom.toString = () => 'Custom Atom'; + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of Custom Atom`], + [`initialized value of Custom Atom to 42`, { value: 42 }], + ]); + }); + + it('should log atoms with a custom toString method in colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + const testAtom = atom(42); + testAtom.toString = () => 'Custom Atom'; + + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %cCustom Atom`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: default; font-weight: normal;', // Custom Atom + ], + [ + `%cinitialized value %cof %cCustom Atom %cto %c42`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: default; font-weight: normal;', // Custom Atom + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 42 + { value: 42 }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-dependencies.test.ts b/tests/atom-logger-dependencies.test.ts new file mode 100644 index 0000000..5856cfd --- /dev/null +++ b/tests/atom-logger-dependencies.test.ts @@ -0,0 +1,910 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; +import { getLoggedStoreState } from '../src/vanilla/create-logged-store.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('dependencies', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log dependencies', () => { + store = createLoggedStore(store, defaultOptions); + const valueAtom = atom(1); + const multiplyAtom = atom(2); + const resultAtom = atom((get) => get(valueAtom) * get(multiplyAtom)); + store.sub(resultAtom, vi.fn()); + store.set(valueAtom, 2); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${resultAtom}`], + [ + `initialized value of ${valueAtom} to 1`, + { + value: 1, + dependents: [`${resultAtom}`], + }, + ], + [ + `initialized value of ${multiplyAtom} to 2`, + { + value: 2, + dependents: [`${resultAtom}`], + }, + ], + [ + `initialized value of ${resultAtom} to 2`, + { + value: 2, + dependencies: [`${valueAtom}`, `${multiplyAtom}`], + }, + ], + [ + `mounted ${valueAtom}`, + { + value: 1, + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${multiplyAtom}`, + { + value: 2, + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${resultAtom}`, + { + value: 2, + dependencies: [`${valueAtom}`, `${multiplyAtom}`], + }, + ], + [ + `transaction 2 : set value of ${valueAtom} to 2`, + { + value: 2, + }, + ], + [ + `changed value of ${valueAtom} from 1 to 2`, + { + newValue: 2, + oldValue: 1, + dependents: [`${resultAtom}`], + }, + ], + [ + `changed value of ${resultAtom} from 2 to 4`, + { + newValue: 4, + oldValue: 2, + dependencies: [`${valueAtom}`, `${multiplyAtom}`], + }, + ], + ]); + }); + + it('should not log dependencies if the only dependencies are private', () => { + store = createLoggedStore(store, defaultOptions); + const privateAtom = atom(0); + privateAtom.debugPrivate = true; + const publicAtom = atom((get) => get(privateAtom) + 1); + store.get(publicAtom); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${publicAtom}`], + [`initialized value of ${publicAtom} to 1`, { value: 1 }], + ]); + }); + + it('should log when an atom dependencies have changed', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + return get(aAtom); + } else { + return get(bAtom); + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${toggleAtom} to false`, + { + value: false, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${testAtom} to 1`, + { + value: 1, + dependencies: [`${toggleAtom}`, `${aAtom}`], + }, + ], + [ + `mounted ${toggleAtom}`, + { + value: false, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${testAtom}`, + { + dependencies: [`${toggleAtom}`, `${aAtom}`], + value: 1, + }, + ], + + [`transaction 2 : set value of ${toggleAtom}`], + [ + `changed value of ${toggleAtom} from false to true`, + { + newValue: true, + oldValue: false, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${bAtom} to 2`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [ + `changed dependencies of ${testAtom}`, + { + oldDependencies: [`${toggleAtom}`, `${aAtom}`], + newDependencies: [`${toggleAtom}`, `${bAtom}`], + }, + ], + [ + `changed value of ${testAtom} from 1 to 2`, + { + dependencies: [`${toggleAtom}`, `${bAtom}`], + newValue: 2, + oldValue: 1, + }, + ], + [ + `mounted ${bAtom}`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [`unmounted ${aAtom}`], + ]); + }); + + it('should not track atom dependencies of private atoms', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + bAtom.debugPrivate = true; + const cAtom = atom((get) => { + get(aAtom); + get(bAtom); + }); + cAtom.debugPrivate = true; + + store.sub(cAtom, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${aAtom} to 1`, { value: 1 }], + [`mounted ${aAtom}`, { value: 1 }], + ]); + + const loggedStoreState = getLoggedStoreState(store)!; + expect(loggedStoreState.dependenciesMap.has(aAtom)).toBeTruthy(); + expect(loggedStoreState.dependenciesMap.has(bAtom)).toBeFalsy(); + expect(loggedStoreState.dependenciesMap.has(cAtom)).toBeFalsy(); + expect(loggedStoreState.prevTransactionDependenciesMap.has(aAtom)).toBeTruthy(); + expect(loggedStoreState.prevTransactionDependenciesMap.has(bAtom)).toBeFalsy(); + expect(loggedStoreState.prevTransactionDependenciesMap.has(cAtom)).toBeFalsy(); + }); + + it('should update value-event dependencies when a dependency is removed and a value change already exists in the transaction', () => { + // Covers add-event-to-transaction.ts:102 — the else-if (existingEvent.dependencies !== undefined) branch + // when a removedDependency event is processed and a non-dependenciesChanged event with + // dependencies also exists in the current transaction + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + // testAtom depends on aAtom and optionally bAtom, and its value changes when toggle changes + const testAtom = atom((get) => { + const toggle = get(toggleAtom); + if (!toggle) { + return get(aAtom) + get(bAtom); + } + return get(aAtom); + }); + + store.sub(testAtom, vi.fn()); + store.set(aAtom, 10); // triggers value change event for testAtom with current deps + store.set(toggleAtom, true); // triggers dep removal + + vi.runAllTimers(); + + // The value-change events for testAtom should reflect the final dependency set + expect(consoleMock.log.mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([expect.stringContaining(`changed dependencies of ${testAtom}`)]), + ]), + ); + }); + + it('should add a dependenciesChanged event when a dep is first removed (no prior dependenciesChanged in transaction)', () => { + // Covers add-event-to-transaction.ts:95-97 (currentTransaction=null / no prior dep event) + // and the pure-deletion case where no hasExistingDepsChangedEvent + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + get(bAtom); + } else { + get(aAtom); + } + return null; + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, true); // removes bAtom dep in its own transaction + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([expect.stringContaining(`changed dependencies of ${testAtom}`)]), + ]), + ); + }); + + it('should log when an atom dependencies are removed', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + get(bAtom); + return; + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${bAtom} to 2`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${testAtom} to undefined`, + { + value: undefined, + dependencies: [`${aAtom}`, `${bAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${bAtom}`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${testAtom}`, + { + value: undefined, + dependencies: [`${aAtom}`, `${bAtom}`], + }, + ], + + [`transaction 2`], + [ + `changed dependencies of ${testAtom}`, + { + oldDependencies: [`${aAtom}`, `${bAtom}`], + newDependencies: [], + }, + ], + [`unmounted ${aAtom}`], + [`unmounted ${bAtom}`], + ]); + }); + + it('should log when an atom dependencies are added', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + return; + } else { + get(aAtom); + get(bAtom); + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${testAtom} to undefined`, + { + value: undefined, + }, + ], + [ + `mounted ${testAtom}`, + { + value: undefined, + }, + ], + + [`transaction 2`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `changed dependencies of ${testAtom}`, + { + oldDependencies: [], + newDependencies: [`${aAtom}`, `${bAtom}`], + }, + ], + [ + `initialized value of ${bAtom} to 2`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${bAtom}`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + ]); + }); + + it('should not log atom dependencies changes if the new dependencies are private', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + bAtom.debugPrivate = true; + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + } else { + get(aAtom); + get(bAtom); // bAtom is added but is private + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${testAtom} to undefined`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${testAtom}`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + ]); + }); + + it('should not log when a private atom dependency is removed', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const privateAtom = atom(2); + privateAtom.debugPrivate = true; + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + get(privateAtom); // private dep that will be removed + } else { + get(aAtom); // only aAtom remains + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + // Visible deps stay the same ([aAtom]) – only the private dep was removed → no dep change logged + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${testAtom} to undefined`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${testAtom}`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + ]); + }); + + it('should log atom dependencies without duplicated atoms', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + get(aAtom); + } else { + get(aAtom); + get(aAtom); + get(bAtom); + get(bAtom); + get(bAtom); + } + }); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `initialized value of ${testAtom} to undefined`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${testAtom}`], + }, + ], + [ + `mounted ${testAtom}`, + { + value: undefined, + dependencies: [`${aAtom}`], + }, + ], + + [`transaction 2`], + [ + `initialized value of ${bAtom} to 2`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + [ + `changed dependencies of ${testAtom}`, + { + oldDependencies: [`${aAtom}`], + newDependencies: [`${aAtom}`, `${bAtom}`], + }, + ], + [ + `mounted ${bAtom}`, + { + value: 2, + dependents: [`${testAtom}`], + }, + ], + ]); + }); + + it('should log atom dependencies changed in colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const aAtom = atom(1); + const bAtom = atom(2); + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom((get) => { + if (!get(toggleAtom)) { + get(aAtom); + } else { + get(bAtom); + } + }); + + const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + const aAtomNumber = /atom(\d+)(.*)/.exec(aAtom.toString())?.[1]; + const bAtomNumber = /atom(\d+)(.*)/.exec(bAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(aAtomNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(bAtomNumber!))).toBeTruthy(); + + store.sub(testAtom, vi.fn()); + store.set(toggleAtom, (prev) => !prev); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %csubscribed %cto %catom%c${testAtomNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // subscribed + `color: #757575; font-weight: normal;`, // to + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 4 + ], + [ + `%cinitialized value %cof %catom%c${aAtomNumber} %cto %c1`, + `color: #0072B2; font-weight: bold;`, // initialized value + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // 1 + { + value: 1, + dependents: [`atom${testAtomNumber}`], + }, + ], + [ + `%cinitialized value %cof %catom%c${testAtomNumber} %cto %cundefined`, + `color: #0072B2; font-weight: bold;`, // initialized value + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 4 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // undefined + { + value: undefined, + dependencies: [`atom${aAtomNumber}`], + }, + ], + [ + `%cmounted %catom%c${aAtomNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + { + value: 1, + dependents: [`atom${testAtomNumber}`], + }, + ], + [ + `%cmounted %catom%c${testAtomNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 4 + { + value: undefined, + dependencies: [`atom${aAtomNumber}`], + }, + ], + [ + `%ctransaction %c2`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 2 + ], + [ + `%cinitialized value %cof %catom%c${bAtomNumber} %cto %c2`, + `color: #0072B2; font-weight: bold;`, // initialized value + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // 2 + { + value: 2, + dependents: [`atom${testAtomNumber}`], + }, + ], + [ + `%cchanged dependencies %cof %catom%c${testAtomNumber}`, + `color: #E69F00; font-weight: bold;`, // changed dependencies + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 4 + { + newDependencies: [`atom${bAtomNumber}`], + oldDependencies: [`atom${aAtomNumber}`], + }, + ], + [ + `%cmounted %catom%c${bAtomNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + { + value: 2, + dependents: [`atom${testAtomNumber}`], + }, + ], + [ + `%cunmounted %catom%c${aAtomNumber}`, + `color: #D55E00; font-weight: bold;`, // unmounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + ]); + }); + + it('should correctly track two dependencies that have the same name', () => { + // Regression test: with Set, two atoms sharing the same debugLabel would be + // deduplicated to one entry. With Set, both atoms are tracked independently. + store = createLoggedStore(store, defaultOptions); + + const dep1 = atom(1); + dep1.debugLabel = 'shared'; + const dep2 = atom(2); + dep2.debugLabel = 'shared'; // same toString() as dep1 + + const resultAtom = atom((get) => get(dep1) + get(dep2)); + + store.sub(resultAtom, vi.fn()); + vi.runAllTimers(); + + // Both deps must appear in the dependencies list, even though they have the same name. + // With the old Set implementation only one 'shared' would appear. + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${resultAtom}`], + [ + `initialized value of ${dep1} to 1`, + { + value: 1, + dependents: [`${resultAtom}`], + }, + ], + [ + `initialized value of ${dep2} to 2`, + { + value: 2, + dependents: [`${resultAtom}`], + }, + ], + [ + `initialized value of ${resultAtom} to 3`, + { + value: 3, + dependencies: [`${dep1}`, `${dep2}`], + }, + ], + [ + `mounted ${dep1}`, + { + value: 1, + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${dep2}`, + { + value: 2, + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${resultAtom}`, + { + value: 3, + dependencies: [`${dep1}`, `${dep2}`], + }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-dependents.test.ts b/tests/atom-logger-dependents.test.ts new file mode 100644 index 0000000..37e85f6 --- /dev/null +++ b/tests/atom-logger-dependents.test.ts @@ -0,0 +1,224 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('dependents', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should not log dependents when not mounted', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom((get) => get(aAtom) * 2); + + store.get(bAtom); // store.get does not mount the atom + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${bAtom}`], + [`initialized value of ${aAtom} to 1`, { value: 1 }], + [`initialized value of ${bAtom} to 2`, { dependencies: [`${aAtom}`], value: 2 }], + ]); + }); + + it('should log dependents when mounted', () => { + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom((get) => get(aAtom) * 2); + + store.sub(bAtom, vi.fn()); // store.sub mounts the atom + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${bAtom}`], + [ + `initialized value of ${aAtom} to 1`, + { + value: 1, + dependents: [`${bAtom}`], + }, + ], + [ + `initialized value of ${bAtom} to 2`, + { + value: 2, + dependencies: [`${aAtom}`], + }, + ], + [ + `mounted ${aAtom}`, + { + value: 1, + dependents: [`${bAtom}`], + }, + ], + [ + `mounted ${bAtom}`, + { + value: 2, + dependencies: [`${aAtom}`], + }, + ], + ]); + }); + + it('should log dependents after dependent is initialized', () => { + store = createLoggedStore(store, defaultOptions); + + const firstAtom = atom('first'); + const secondAtom = atom('second'); + const resultAtom = atom((get) => get(firstAtom) + ' ' + get(secondAtom)); + + // secondAtom doesn't have yet dependents yet since resultAtom is not mounted yet + store.get(secondAtom); + + // secondAtom should have dependents now + store.sub(resultAtom, vi.fn()); + + // change his value to trigger the log + store.set(secondAtom, '2nd'); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${secondAtom}`], + [ + `initialized value of ${secondAtom} to "second"`, + { + value: 'second', + // no dependents here since resultAtom is not mounted yet + }, + ], + + [`transaction 2 : subscribed to ${resultAtom}`], + [ + `initialized value of ${firstAtom} to "first"`, + { + value: 'first', + dependents: [`${resultAtom}`], + }, + ], + [ + `initialized value of ${resultAtom} to "first second"`, + { + value: 'first second', + dependencies: [`${firstAtom}`, `${secondAtom}`], + }, + ], + [ + `mounted ${firstAtom}`, + { + value: 'first', + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${secondAtom}`, + { + value: 'second', + dependents: [`${resultAtom}`], + }, + ], + [ + `mounted ${resultAtom}`, + { + value: 'first second', + dependencies: [`${firstAtom}`, `${secondAtom}`], + }, + ], + + [ + `transaction 3 : set value of ${secondAtom} to "2nd"`, + { + value: '2nd', + }, + ], + [ + `changed value of ${secondAtom} from "second" to "2nd"`, + { + newValue: '2nd', + oldValue: 'second', + dependents: [`${resultAtom}`], // here he is + }, + ], + [ + `changed value of ${resultAtom} from "first second" to "first 2nd"`, + { + newValue: 'first 2nd', + oldValue: 'first second', + dependencies: [`${firstAtom}`, `${secondAtom}`], + }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-destroyed.test.ts b/tests/atom-logger-destroyed.test.ts new file mode 100644 index 0000000..bcb23b5 --- /dev/null +++ b/tests/atom-logger-destroyed.test.ts @@ -0,0 +1,199 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; +import type { AtomId } from '../src/vanilla/types/event.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('destroyed', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + let finalizationRegistryRegisterMock: Mock; + let finalizationRegistryUnregisterMock: Mock; + let registeredCallback: ((heldValue: AtomId) => void) | null; + + beforeEach(() => { + finalizationRegistryRegisterMock = vi.fn(); + finalizationRegistryUnregisterMock = vi.fn(); + registeredCallback = null; + vi.spyOn(globalThis, 'FinalizationRegistry').mockImplementation( + function (callback): FinalizationRegistry { + registeredCallback = callback; + return { + register: finalizationRegistryRegisterMock, + unregister: finalizationRegistryUnregisterMock, + [Symbol.toStringTag]: 'FinalizationRegistry', + }; + }, + ); + }); + + it('should register atoms with FinalizationRegistry for garbage collection tracking', () => { + store = createLoggedStore(store, defaultOptions); + + expect(finalizationRegistryRegisterMock).not.toHaveBeenCalled(); + + const testAtom = atom(42); + store.get(testAtom); + + expect(finalizationRegistryRegisterMock).toHaveBeenCalled(); + expect(finalizationRegistryRegisterMock.mock.calls).toEqual([[testAtom, testAtom.toString()]]); + }); + + it('should log when an atom is garbage collected', () => { + store = createLoggedStore(store, defaultOptions); + + expect(registeredCallback).not.toBeNull(); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + registeredCallback!(testAtom.toString()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + + [`transaction 2`], + [`destroyed ${testAtom}`], + ]); + }); + + it('should log when an atom is garbage collected with colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + expect(registeredCallback).not.toBeNull(); + + const testAtom = atom(42); + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + registeredCallback!(testAtom.toString()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 1 + ], + [ + `%cdestroyed %catom%c${atomNumber}`, + `color: #D55E00; font-weight: bold;`, // destroyed + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + ]); + }); + + it('should not log when an atom is garbage collected if the store is disabled', () => { + store = createLoggedStore(store, { ...defaultOptions, enabled: false }); + + expect(registeredCallback).not.toBeNull(); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + registeredCallback!(testAtom.toString()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([]); + }); + + it('should not log when an atom is garbage collected if the store just got disabled', () => { + store = createLoggedStore(store, { ...defaultOptions, enabled: true }); + + expect(registeredCallback).not.toBeNull(); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + store = createLoggedStore(store, { ...defaultOptions, enabled: false }); + + registeredCallback!(testAtom.toString()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); +}); diff --git a/tests/atom-logger-errors.test.ts b/tests/atom-logger-errors.test.ts new file mode 100644 index 0000000..260e53c --- /dev/null +++ b/tests/atom-logger-errors.test.ts @@ -0,0 +1,157 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('errors', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log error values without stringifying when stringifyValues is false', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringifyValues: false }), + }); + + const errorAtom = atom(() => Promise.reject(new Error('initial error'))); + + store.sub(errorAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + + vi.runAllTimers(); + + // Covers event-log-pipeline.ts line 265: stringifyValues=false, isNewValueError=true, no old value + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${errorAtom}`], + [`pending initial promise of ${errorAtom}`], + [`mounted ${errorAtom}`], + [`rejected initial promise of ${errorAtom} to`, new Error('initial error')], + ]); + }); + + it('should log old error and new error without stringifying when stringifyValues is false', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringifyValues: false }), + }); + + const depAtom = atom(0); + depAtom.debugPrivate = true; + let count = 0; + // eslint-disable-next-line @typescript-eslint/require-await + const errorAtom = atom(async (get) => { + get(depAtom); + count += 1; + throw new Error(`error ${count}`); + }); + + store.sub(errorAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + + vi.clearAllMocks(); + + // Change dep so errorAtom re-runs: old error → new pending → new error + store.set(depAtom, 1); + await vi.advanceTimersByTimeAsync(250); + + vi.runAllTimers(); + + // Covers event-log-pipeline.ts lines 215, 262: + // - line 215: stringifyValues=false, old error shown in pending log (old value was error) + // - line 262: stringifyValues=false, hasOldValue && isOldValueError, new value is also error + const calls = consoleMock.log.mock.calls; + expect(calls).toContainEqual(expect.arrayContaining([expect.stringContaining('pending')])); + expect(calls).toContainEqual(expect.arrayContaining([expect.stringContaining('rejected')])); + }); + + it('should log custom errors', async () => { + store = createLoggedStore(store, defaultOptions); + + const customError = RangeError('Custom error message'); + const promiseAtom = atom(() => { + return new Promise((_, reject) => { + setTimeout(() => { + reject(customError); + }, 0); + }); + }); + + const promise = store.get(promiseAtom); + + await vi.advanceTimersByTimeAsync(1000); + + await expect(promise).rejects.toThrow('Custom error message'); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [ + `rejected initial promise of ${promiseAtom} to RangeError: Custom error message`, + { error: customError }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-formatter.test.ts b/tests/atom-logger-formatter.test.ts new file mode 100644 index 0000000..97b7b6a --- /dev/null +++ b/tests/atom-logger-formatter.test.ts @@ -0,0 +1,737 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AtomTransactionTypes, createLoggedStore, type AtomLoggerOptions } from '../src/index.js'; +import { AtomEventTypes } from '../src/vanilla/types/event.js'; +import type { AtomLoggerFormatter } from '../src/vanilla/types/formatter.js'; +import type { AtomTransaction } from '../src/vanilla/types/transaction.js'; + +describe('custom formatter', () => { + let store: ReturnType; + + beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + store = createStore(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('should call the custom formatter with each completed transaction', async () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + const subFn = vi.fn(); + + // Synchronous atoms + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const testAtom = atom(42); + const derivedAtom = atom((get) => { + if (get(toggleAtom)) return 0; + return get(testAtom) + 1; + }); + + // Async A: initialPromisePending → aborted → initialPromisePending → initialPromiseResolved + const depA = atom(0); + depA.debugPrivate = true; + let resolveA!: (v: number) => void; + const asyncAtomA = atom(async (get, { signal }) => { + const v = get(depA); + if (v === 0) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted')); + }); + }); + return new Promise((r) => { + resolveA = r; + }); + }); + const listenerA = vi.fn(); + + // Async B: changedPromisePending → changedPromiseResolved + const changedAtomB = atom(0); + const listenerB = vi.fn(); + + // Async C: changedPromisePending → changedPromiseAborted → changedPromisePending → changedPromiseRejected + const changedAtomC = atom(0); + const listenerC = vi.fn(); + + // Phase 1: synchronous transactions + store.get(testAtom); // tx1 + store.set(testAtom, 43); // tx2 + const unsub = store.sub(derivedAtom, subFn); // tx3 + store.set(toggleAtom, true); // tx4 + unsub(); // tx5 + vi.runAllTimers(); + + // Phase 2: async initial promise events + store.sub(asyncAtomA, listenerA); // tx6: initialPromisePending + mounted + await vi.advanceTimersByTimeAsync(0); + store.set(depA, 1); // tx7: initialPromiseAborted + initialPromisePending + await vi.advanceTimersByTimeAsync(0); + resolveA(77); + await vi.advanceTimersByTimeAsync(0); // tx8: initialPromiseResolved(77) + + // Phase 3: changedPromisePending → changedPromiseResolved + store.sub(changedAtomB, listenerB); // tx9: initialized(0) + mounted(0) + vi.runAllTimers(); + const resolvedPromise = Promise.resolve(99); + store.set(changedAtomB, resolvedPromise); // tx10: changedPromisePending(oldValue=0) + await vi.advanceTimersByTimeAsync(0); // tx11: changedPromiseResolved(oldValue=0, newValue=99) + + // Phase 4: changedPromisePending → aborted → changedPromiseRejected + store.sub(changedAtomC, listenerC); // tx12: initialized(0) + mounted(0) + vi.runAllTimers(); + const pendingPromise = new Promise(() => {}); + store.set(changedAtomC, pendingPromise); // tx13: changedPromisePending(oldValue=0) + await vi.advanceTimersByTimeAsync(0); + const rejectedPromise = Promise.reject(new Error('rejected')); + rejectedPromise.catch(() => {}); + store.set(changedAtomC, rejectedPromise); // tx14: changedPromiseAborted(0) + changedPromisePending(0) + await vi.advanceTimersByTimeAsync(0); // tx15: changedPromiseRejected(oldValue=0, error) + + vi.runAllTimers(); + + expect(transactions).toHaveLength(15); + expect(transactions).toEqual([ + // ── tx1: store.get(testAtom) ────────────────────────────────────────── + { + type: AtomTransactionTypes.storeGet, + transactionNumber: 1, + atom: testAtom, + events: [{ type: AtomEventTypes.initialized, atom: testAtom, value: 42 }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx2: store.set(testAtom, 43) ───────────────────────────────────── + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 2, + atom: testAtom, + args: [43], + result: undefined, + events: [{ type: AtomEventTypes.changed, atom: testAtom, newValue: 43, oldValue: 42 }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx3: store.sub(derivedAtom, subFn) ─────────────────────────────── + { + type: AtomTransactionTypes.storeSubscribe, + transactionNumber: 3, + atom: derivedAtom, + listener: subFn, + events: [ + { + type: AtomEventTypes.initialized, + atom: derivedAtom, + value: 44, + dependencies: new Set([testAtom]), + }, + { + type: AtomEventTypes.mounted, + atom: testAtom, + value: 43, + dependents: new Set([derivedAtom]), + }, + { + type: AtomEventTypes.mounted, + atom: derivedAtom, + value: 44, + dependencies: new Set([testAtom]), + }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx4: store.set(toggleAtom, true) — toggleAtom is private ───────── + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 4, + atom: undefined, + args: [true], + result: undefined, + events: [ + { type: AtomEventTypes.changed, atom: derivedAtom, newValue: 0, oldValue: 44 }, + { + type: AtomEventTypes.dependenciesChanged, + atom: derivedAtom, + oldDependencies: new Set([testAtom]), + removedDependencies: new Set([testAtom]), + }, + { type: AtomEventTypes.unmounted, atom: testAtom }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx5: unsub() — storeUnsubscribe ────────────────────────────────── + { + type: AtomTransactionTypes.storeUnsubscribe, + transactionNumber: 5, + atom: derivedAtom, + listener: subFn, + events: [{ type: AtomEventTypes.unmounted, atom: derivedAtom }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx6: store.sub(asyncAtomA) ──────────────────────────────────────── + { + type: AtomTransactionTypes.storeSubscribe, + transactionNumber: 6, + atom: asyncAtomA, + listener: listenerA, + events: [ + { type: AtomEventTypes.initialPromisePending, atom: asyncAtomA }, + { type: AtomEventTypes.mounted, atom: asyncAtomA }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx7: store.set(depA, 1) — depA is private ──────────────────────── + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 7, + atom: undefined, + args: [1], + result: undefined, + events: [ + { type: AtomEventTypes.initialPromiseAborted, atom: asyncAtomA }, + { type: AtomEventTypes.initialPromisePending, atom: asyncAtomA }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx8: promiseResolved — resolveA(77) ────────────────────────────── + { + type: AtomTransactionTypes.promiseResolved, + transactionNumber: 8, + atom: asyncAtomA, + events: [{ type: AtomEventTypes.initialPromiseResolved, atom: asyncAtomA, value: 77 }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx9: store.sub(changedAtomB) ───────────────────────────────────── + { + type: AtomTransactionTypes.storeSubscribe, + transactionNumber: 9, + atom: changedAtomB, + listener: listenerB, + events: [ + { type: AtomEventTypes.initialized, atom: changedAtomB, value: 0 }, + { type: AtomEventTypes.mounted, atom: changedAtomB, value: 0 }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx10: store.set(changedAtomB, resolvedPromise) ─────────────────── + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 10, + atom: changedAtomB, + args: [resolvedPromise], + result: undefined, + events: [{ type: AtomEventTypes.changedPromisePending, atom: changedAtomB, oldValue: 0 }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx11: promiseResolved — resolvedPromise settles ─────────────────── + { + type: AtomTransactionTypes.promiseResolved, + transactionNumber: 11, + atom: changedAtomB, + events: [ + { + type: AtomEventTypes.changedPromiseResolved, + atom: changedAtomB, + oldValue: 0, + newValue: 99, + }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx12: store.sub(changedAtomC) ───────────────────────────────────── + { + type: AtomTransactionTypes.storeSubscribe, + transactionNumber: 12, + atom: changedAtomC, + listener: listenerC, + events: [ + { type: AtomEventTypes.initialized, atom: changedAtomC, value: 0 }, + { type: AtomEventTypes.mounted, atom: changedAtomC, value: 0 }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx13: store.set(changedAtomC, pendingPromise) ──────────────────── + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 13, + atom: changedAtomC, + args: [pendingPromise], + result: undefined, + events: [{ type: AtomEventTypes.changedPromisePending, atom: changedAtomC, oldValue: 0 }], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx14: store.set(changedAtomC, rejectedPromise) — aborts pendingPromise + { + type: AtomTransactionTypes.storeSet, + transactionNumber: 14, + atom: changedAtomC, + args: [rejectedPromise], + result: undefined, + events: [ + { type: AtomEventTypes.changedPromiseAborted, atom: changedAtomC, oldValue: 0 }, + { type: AtomEventTypes.changedPromisePending, atom: changedAtomC, oldValue: 0 }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + // ── tx15: promiseRejected — rejectedPromise settles ─────────────────── + { + type: AtomTransactionTypes.promiseRejected, + transactionNumber: 15, + atom: changedAtomC, + events: [ + { + type: AtomEventTypes.changedPromiseRejected, + atom: changedAtomC, + oldValue: 0, + error: new Error('rejected'), + }, + ], + startTimestamp: 0, + endTimestamp: 0, + }, + ]); + }); + + it('should not set empty dependencies on dependenciesChanged events', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + const toggleAtom = atom(false); + toggleAtom.debugPrivate = true; + const depAtom = atom(1); + // testAtom has no deps when toggleAtom is false, gains depAtom when true + const testAtom = atom((get) => { + if (get(toggleAtom)) get(depAtom); + }); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + store.sub(testAtom, vi.fn()); // mount with no deps + vi.runAllTimers(); + transactions.length = 0; // only care about dep-change transactions below + + // Add depAtom as a dependency + store.set(toggleAtom, true); + vi.runAllTimers(); + + const addDepEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.type === AtomEventTypes.dependenciesChanged)!; + expect(addDepEvent).toBeDefined(); + expect(addDepEvent).toHaveProperty('addedDependencies'); // depAtom was added + expect(addDepEvent).toHaveProperty('dependencies'); // now depends on depAtom + expect(addDepEvent).not.toHaveProperty('oldDependencies'); // was empty — absent + expect(addDepEvent).not.toHaveProperty('removedDependencies'); // none removed — absent + + transactions.length = 0; + + // Remove depAtom as a dependency + store.set(toggleAtom, false); + vi.runAllTimers(); + + const removeDepEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.type === AtomEventTypes.dependenciesChanged)!; + expect(removeDepEvent).toBeDefined(); + expect(removeDepEvent).toHaveProperty('removedDependencies'); // depAtom was removed + expect(removeDepEvent).toHaveProperty('oldDependencies'); // had depAtom before + expect(removeDepEvent).not.toHaveProperty('addedDependencies'); // none added — absent + expect(removeDepEvent).not.toHaveProperty('dependencies'); // now empty — absent + }); + + it('should not set dependents on events when atom has no dependents', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + const aAtom = atom(42); + const bAtom = atom((get) => get(aAtom) * 2); + + // Absent: store.get does not mount atoms + store.get(aAtom); + vi.runAllTimers(); + + const initEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.atom === aAtom && e.type === AtomEventTypes.initialized)!; + expect(initEvent).not.toHaveProperty('dependents'); + transactions.length = 0; + + // Present: sub bAtom mounts both + store.sub(bAtom, vi.fn()); + transactions.length = 0; + store.set(aAtom, 43); + vi.runAllTimers(); + + const changedEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.atom === aAtom && e.type === AtomEventTypes.changed)!; + expect(changedEvent).toHaveProperty('dependents'); + expect(changedEvent.dependents).toEqual(new Set([bAtom])); + }); + + it('should not set pendingPromises on events when atom has no pending promises', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + // Absent: plain sync atom has no pending promise dependents + const syncAtom = atom(42); + store.get(syncAtom); + vi.runAllTimers(); + + const syncEvent = transactions.flatMap((tx) => tx.events).find((e) => e.atom === syncAtom)!; + expect(syncEvent).not.toHaveProperty('pendingPromises'); + expect(syncEvent).not.toHaveProperty('dependents'); + + expect(transactions).toEqual([ + expect.objectContaining({ + events: [ + { + type: AtomEventTypes.initialized, + atom: syncAtom, + value: 42, + }, + ], + }), + ]); + + transactions.length = 0; + + // Present: async promiseAtom depends on dependencyAtom and stays pending + const dependencyAtom = atom(0); + const promiseAtom = atom(async (get) => { + get(dependencyAtom); + await new Promise(() => {}); // never resolves during this test + }); + store.sub(promiseAtom, vi.fn()); + vi.runAllTimers(); + + const mountedEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.atom === dependencyAtom && e.type === AtomEventTypes.mounted)!; + expect(mountedEvent).toHaveProperty('pendingPromises'); + expect(mountedEvent.pendingPromises).toEqual(new Set([promiseAtom])); + expect(mountedEvent).toHaveProperty('dependents'); + expect(mountedEvent.dependents).toEqual(new Set([promiseAtom])); + + // initialized fires before atomState.p is populated → needs retroactive update too + const initEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.atom === dependencyAtom && e.type === AtomEventTypes.initialized)!; + expect(initEvent).toBeDefined(); + expect(initEvent).toHaveProperty('pendingPromises'); + expect(initEvent.pendingPromises).toEqual(new Set([promiseAtom])); + + expect(transactions).toEqual([ + expect.objectContaining({ + events: [ + { + type: AtomEventTypes.initialized, + atom: dependencyAtom, + value: 0, + dependents: new Set([promiseAtom]), + pendingPromises: new Set([promiseAtom]), + }, + { + type: AtomEventTypes.initialPromisePending, + atom: promiseAtom, + dependencies: new Set([dependencyAtom]), + }, + { + type: AtomEventTypes.mounted, + atom: dependencyAtom, + value: 0, + dependents: new Set([promiseAtom]), + pendingPromises: new Set([promiseAtom]), + }, + { + type: AtomEventTypes.mounted, + atom: promiseAtom, + dependencies: new Set([dependencyAtom]), + }, + ], + }), + ]); + }); + + it('should not set dependencies on events when atom has no dependencies', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + // Absent: primitive atom + const primitiveAtom = atom(0); + store.get(primitiveAtom); + vi.runAllTimers(); + + const initEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.type === AtomEventTypes.initialized && e.atom === primitiveAtom)!; + expect(initEvent).not.toHaveProperty('dependencies'); + + expect(transactions).toEqual([ + expect.objectContaining({ + events: [ + { + type: AtomEventTypes.initialized, + atom: primitiveAtom, + value: 0, + }, + ], + }), + ]); + + transactions.length = 0; + store.set(primitiveAtom, 1); + vi.runAllTimers(); + + const changedEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.type === AtomEventTypes.changed && e.atom === primitiveAtom)!; + expect(changedEvent).not.toHaveProperty('dependencies'); + + expect(transactions).toEqual([ + expect.objectContaining({ + events: [ + { + type: AtomEventTypes.changed, + atom: primitiveAtom, + newValue: 1, + oldValue: 0, + }, + ], + }), + ]); + + // Present: derived atom reads primitiveAtom + transactions.length = 0; + const derivedAtom = atom((get) => get(primitiveAtom) * 2); + store.get(derivedAtom); + vi.runAllTimers(); + + const derivedInitEvent = transactions + .flatMap((tx) => tx.events) + .find((e) => e.type === AtomEventTypes.initialized && e.atom === derivedAtom)!; + expect(derivedInitEvent).toHaveProperty('dependencies'); + expect(derivedInitEvent.dependencies).toEqual(new Set([primitiveAtom])); + + expect(transactions).toEqual([ + expect.objectContaining({ + events: [ + { + type: AtomEventTypes.initialized, + atom: derivedAtom, + value: 2, + dependencies: new Set([primitiveAtom]), + }, + ], + }), + ]); + }); + + it('should call the custom formatter for every transaction', () => { + const callCount = { value: 0 }; + const customFormatter: AtomLoggerFormatter = () => { + callCount.value += 1; + }; + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + const atomA = atom(1); + const atomB = atom(2); + + store.get(atomA); + vi.runAllTimers(); + + store.set(atomA, 10); + vi.runAllTimers(); + + store.get(atomB); + vi.runAllTimers(); + + expect(callCount.value).toBe(3); + }); + + it('should provide correct transaction data to the custom formatter', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { formatter: customFormatter, synchronous: true }); + + const counterAtom = atom(0); + store.set(counterAtom, 99); + vi.runAllTimers(); + + expect(transactions).toHaveLength(1); + const tx = transactions[0]!; + expect(tx.events.length).toBeGreaterThan(0); + expect(tx.transactionNumber).toBe(1); + expect(tx.startTimestamp).toBeGreaterThanOrEqual(0); + expect(tx.endTimestamp).toBeGreaterThanOrEqual(tx.startTimestamp); + }); + + it('should call a new formatter after updating the formatter directly', () => { + const firstFormatter = vi.fn(); + const secondFormatter = vi.fn(); + + const options: AtomLoggerOptions = { + formatter: firstFormatter, + synchronous: true, + }; + + store = createLoggedStore(store, options); + + const testAtom = atom(0); + store.get(testAtom); + vi.runAllTimers(); + + expect(firstFormatter).toHaveBeenCalledTimes(1); + expect(secondFormatter).toHaveBeenCalledTimes(0); + + // Replace formatter by updating the options directly + options.formatter = secondFormatter; + + store.set(testAtom, 1); + vi.runAllTimers(); + + expect(firstFormatter).toHaveBeenCalledTimes(1); + expect(secondFormatter).toHaveBeenCalledTimes(1); + }); + + it('should not call the formatter when the logger is disabled', () => { + const customFormatter = vi.fn(); + + store = createLoggedStore(store, { + formatter: customFormatter, + enabled: false, + synchronous: true, + }); + + const testAtom = atom(42); + store.get(testAtom); + vi.runAllTimers(); + + expect(customFormatter).not.toHaveBeenCalled(); + }); + + it('should not call the formatter for private atoms when shouldShowPrivateAtoms is false', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + store = createLoggedStore(store, { + formatter: customFormatter, + shouldShowPrivateAtoms: false, + synchronous: true, + }); + + const privateAtom = atom(0); + privateAtom.debugPrivate = true; + + store.get(privateAtom); + vi.runAllTimers(); + + // Private atom access creates no visible transaction events + const hasPrivateEvent = transactions.some((tx) => + tx.events.some((e) => e.atom === privateAtom.toString()), + ); + expect(hasPrivateEvent).toBe(false); + }); + + it('should allow shouldShowAtom to filter which atoms reach the formatter', () => { + const transactions: AtomTransaction[] = []; + const customFormatter: AtomLoggerFormatter = (transaction) => transactions.push(transaction); + + const allowedAtom = atom(1); + const ignoredAtom = atom(2); + + store = createLoggedStore(store, { + formatter: customFormatter, + shouldShowAtom: (a) => a === allowedAtom, + synchronous: true, + }); + + store.get(allowedAtom); + vi.runAllTimers(); + + store.get(ignoredAtom); + vi.runAllTimers(); + + // Only transactions for allowedAtom events should appear + const mentionsIgnored = transactions.some((tx) => + tx.events.some((e) => e.atom === ignoredAtom.toString()), + ); + expect(mentionsIgnored).toBe(false); + expect(transactions.length).toBeGreaterThanOrEqual(1); + }); + + it('should work with a formatter that implements structured logging', () => { + interface LogEntry { + level: string; + transactionNumber: number; + atomId: string | undefined; + eventCount: number; + } + + const logs: LogEntry[] = []; + const structuredFormatter: AtomLoggerFormatter = (transaction) => { + logs.push({ + level: 'info', + transactionNumber: transaction.transactionNumber, + atomId: + typeof transaction.atom === 'string' ? transaction.atom : transaction.atom?.toString(), + eventCount: transaction.events.length, + }); + }; + + store = createLoggedStore(store, { formatter: structuredFormatter, synchronous: true }); + + const myAtom = atom(0); + store.get(myAtom); + vi.runAllTimers(); + + store.set(myAtom, 42); + vi.runAllTimers(); + + expect(logs).toHaveLength(2); + expect(logs[0]).toMatchObject({ level: 'info', transactionNumber: 1 }); + expect(logs[1]).toMatchObject({ level: 'info', transactionNumber: 2 }); + }); + + it('should store the formatter in the loggerState', () => { + const customFormatter: AtomLoggerFormatter = vi.fn(); + const options: AtomLoggerOptions = { formatter: customFormatter }; + store = createLoggedStore(store, options); + expect(options.formatter).toBe(customFormatter); + }); + + it('should use a default consoleFormatter when no formatter is provided', () => { + const options: AtomLoggerOptions = {}; + store = createLoggedStore(store, options); + expect(typeof options.formatter).toBe('function'); + }); +}); diff --git a/tests/atom-logger-groups.test.ts b/tests/atom-logger-groups.test.ts new file mode 100644 index 0000000..49b4928 --- /dev/null +++ b/tests/atom-logger-groups.test.ts @@ -0,0 +1,355 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('groups', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should group transactions if groupTransactions is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, groupTransactions: true }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should collapse transaction groups if collapseTransactions is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + collapseTransactions: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ]); + expect(consoleMock.log.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should group events if groupEvents is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, groupEvents: true }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([[`initialized value of ${testAtom} to 0`]]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ['value', 0], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should collapse event groups if collapseEvents is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupEvents: true, + collapseEvents: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`], + ]); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ['value', 0], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should group transactions and events if both groupTransactions and groupEvents are true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + groupEvents: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`], + ]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([['value', 0]]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); + }); + + it('should group collapsed events and transactions if both collapseTransactions and collapseEvents are true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + groupEvents: true, + collapseTransactions: true, + collapseEvents: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`], + ]); + expect(consoleMock.log.mock.calls).toEqual([['value', 0]]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); + }); + + it('should log collapsed transaction groups even if logger.group is not defined', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + collapseTransactions: true, + logger: { + log: consoleMock.log, + group: undefined, + groupCollapsed: consoleMock.groupCollapsed, + groupEnd: consoleMock.groupEnd, + }, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ]); + expect(consoleMock.log.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should log event groups even if logger.group is not defined', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupEvents: true, + collapseEvents: true, + logger: { + log: consoleMock.log, + group: undefined, + groupCollapsed: consoleMock.groupCollapsed, + groupEnd: consoleMock.groupEnd, + }, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`], + ]); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ['value', 0], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should log transaction groups even if logger.groupCollapsed is not defined', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + collapseTransactions: false, + logger: { + log: consoleMock.log, + group: consoleMock.group, + groupCollapsed: undefined, + groupEnd: consoleMock.groupEnd, + }, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should log event groups even if logger.groupCollapsed is not defined', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupEvents: true, + collapseEvents: false, + logger: { + log: consoleMock.log, + group: consoleMock.group, + groupCollapsed: undefined, + groupEnd: consoleMock.groupEnd, + }, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([[`initialized value of ${testAtom} to 0`]]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + ['value', 0], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); + }); + + it('should not log transaction and event groups if logger.groupEnd is not defined', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + groupTransactions: true, + groupEvents: true, + logger: { + log: consoleMock.log, + group: consoleMock.group, + groupCollapsed: consoleMock.groupCollapsed, + groupEnd: undefined, + }, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([]); + }); +}); diff --git a/tests/atom-logger-mounting.test.ts b/tests/atom-logger-mounting.test.ts new file mode 100644 index 0000000..2544e84 --- /dev/null +++ b/tests/atom-logger-mounting.test.ts @@ -0,0 +1,249 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('mounting', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should unsubscribe while inside a transaction without starting a new one', () => { + // Covers on-store-sub.ts lines 40-51: doStartTransaction=false in onStoreUnsubscribe + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(0); + let capturedUnsubscribe: (() => void) | undefined; + + const triggerAtom = atom(null, (_get, set) => { + // Subscribe and immediately unsubscribe from within a transaction (set call) + capturedUnsubscribe = store.sub(testAtom, vi.fn()); + set(testAtom, 1); + }); + + store.set(triggerAtom); + + vi.runAllTimers(); + + expect(capturedUnsubscribe).toBeDefined(); + + // Now unsubscribe from within another transaction (isInsideTransaction=true) + const unsubAtom = atom(null, () => { + capturedUnsubscribe!(); + }); + store.set(unsubAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); + }); + + it('should log mounted and unmounted atoms', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + + const unmount = store.sub(testAtom, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + [`mounted ${testAtom}`, { value: 42 }], + ]); + + unmount(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + [`mounted ${testAtom}`, { value: 42 }], + + [`transaction 2 : unsubscribed from ${testAtom}`], + [`unmounted ${testAtom}`], + ]); + }); + + it('should log mounted and unmounted atoms in colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const testAtom = atom(42); + + const unmount = store.sub(testAtom, vi.fn()); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %csubscribed %cto %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #009E73; font-weight: bold;', // subscribed + 'color: #757575; font-weight: normal;', // to + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c42`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 42 + { value: 42 }, + ], + [ + `%cmounted %catom%c${atomNumber}`, + 'color: #009E73; font-weight: bold;', // mounted + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + { value: 42 }, + ], + ]); + + vi.clearAllMocks(); + + unmount(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c2 %c: %cunsubscribed %cfrom %catom%c${atomNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 2 + `color: #757575; font-weight: normal;`, // : + `color: #D55E00; font-weight: bold;`, // unsubscribed + `color: #757575; font-weight: normal;`, // from + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + [ + `%cunmounted %catom%c${atomNumber}`, + `color: #D55E00; font-weight: bold;`, // unmounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + ]); + }); + + it('should log atom value when mounted', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + + store.sub(testAtom, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + [`mounted ${testAtom}`, { value: 42 }], + ]); + }); + + it('should log atom promise value when mounted', async () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(42); + }, 1000); + }); + }); + + void store.get(testAtom); // resolves the promise + await vi.advanceTimersByTimeAsync(1000); + + store.sub(testAtom, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`pending initial promise of ${testAtom}`], + + [`transaction 2 : resolved promise of ${testAtom}`], + [`resolved initial promise of ${testAtom} to 42`, { value: 42 }], + + [`transaction 3 : subscribed to ${testAtom}`], + [`mounted ${testAtom}`, { value: 42 }], + ]); + }); +}); diff --git a/tests/atom-logger-options-asynchronous.test.ts b/tests/atom-logger-options-asynchronous.test.ts new file mode 100644 index 0000000..32ae458 --- /dev/null +++ b/tests/atom-logger-options-asynchronous.test.ts @@ -0,0 +1,427 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('transactionDebounceMs', () => { + it('should log transactions with debounce by default', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom('trans-1.0'); + const setTestAtom = atom(null, (get, set) => { + setTimeout(() => { + // This is a new unknown transaction + set(testAtom, 'trans-1.1'); + vi.advanceTimersByTime(249); // debounce + set(testAtom, 'trans-1.2'); + vi.advanceTimersByTime(249); // debounce + set(testAtom, 'trans-1.3'); + + // Will be in another transaction if >= 250ms + vi.advanceTimersByTime(250); + set(testAtom, 'trans-2.1'); + }, 1000); + }); + store.set(setTestAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], + [ + `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, + { + newValue: 'trans-1.3', + oldValues: ['trans-1.1', 'trans-1.2'], + }, + ], + + [`transaction 2`], + [ + `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, + { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, + ], + ]); + }); + + it('should log transactions with debounce with transactionDebounceMs option', () => { + const transactionDebounceMs = 100; + + store = createLoggedStore(store, { + ...defaultOptions, + transactionDebounceMs, + }); + + const testAtom = atom('trans-1.0'); + const setTestAtom = atom(null, (get, set) => { + setTimeout(() => { + // This is a new unknown transaction + set(testAtom, 'trans-1.1'); + vi.advanceTimersByTime(transactionDebounceMs - 1); // debounce + set(testAtom, 'trans-1.2'); + vi.advanceTimersByTime(transactionDebounceMs - 1); // debounce + set(testAtom, 'trans-1.3'); + + // Will be in another transaction if >= transactionDebounceMs + vi.advanceTimersByTime(transactionDebounceMs); + set(testAtom, 'trans-2.1'); + }, 1000); + }); + store.set(setTestAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], + [ + `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, + { + newValue: 'trans-1.3', + oldValues: ['trans-1.1', 'trans-1.2'], + }, + ], + + [`transaction 2`], + [ + `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, + { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, + ], + ]); + }); + + it('should log transactions without debounce when transactionDebounceMs is 0', () => { + store = createLoggedStore(store, { + ...defaultOptions, + transactionDebounceMs: 0, + }); + + const testAtom = atom('trans-1.0'); + const setTestAtom = atom(null, (get, set) => { + setTimeout(() => { + set(testAtom, 'trans-1.1'); // This is a new unknown transaction + set(testAtom, 'trans-1.2'); // This is a new unknown transaction + vi.advanceTimersByTime(1); + set(testAtom, 'trans-2.1'); // This is a new unknown transaction + }, 1000); + }); + store.set(setTestAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], + + [`transaction 2`], + [ + `changed value of ${testAtom} from "trans-1.1" to "trans-1.2"`, + { newValue: 'trans-1.2', oldValue: 'trans-1.1' }, + ], + + [`transaction 3`], + [ + `changed value of ${testAtom} from "trans-1.2" to "trans-2.1"`, + { newValue: 'trans-2.1', oldValue: 'trans-1.2' }, + ], + ]); + }); + }); + + describe('requestIdleCallbackTimeoutMs', () => { + const transactionCallbacks: (() => void)[] = []; + let requestIdleCallbackMockFn: Mock; + + beforeEach(() => { + requestIdleCallbackMockFn = vi.fn((cb: IdleRequestCallback) => { + transactionCallbacks.push(() => { + cb({ didTimeout: false, timeRemaining: () => 50 }); + }); + return 1; + }); + globalThis.requestIdleCallback = requestIdleCallbackMockFn; + }); + + afterEach(() => { + delete (globalThis as Partial).requestIdleCallback; + }); + + it('should schedule and log transactions using requestIdleCallback by default', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + store.get(testAtom); + vi.runAllTimers(); + + expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { + timeout: 250, + }); + + expect(consoleMock.log.mock.calls).toEqual([]); + requestIdleCallbackMockFn.mockClear(); + transactionCallbacks.shift()!(); // Run the first transaction + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + }); + + it('should schedule and log transactions using requestIdleCallback with requestIdleCallbackTimeoutMs option', () => { + store = createLoggedStore(store, { + ...defaultOptions, + requestIdleCallbackTimeoutMs: 666, + }); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + store.get(testAtom); + vi.runAllTimers(); + + expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { + timeout: 666, + }); + + expect(consoleMock.log.mock.calls).toEqual([]); + requestIdleCallbackMockFn.mockClear(); + transactionCallbacks.shift()!(); // Run the first transaction + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + }); + + it('should schedule and log transactions using requestIdleCallback without timeout with requestIdleCallbackTimeoutMs option to 0', () => { + store = createLoggedStore(store, { + ...defaultOptions, + requestIdleCallbackTimeoutMs: 0, + }); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + store.get(testAtom); + vi.runAllTimers(); + + expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { + timeout: 0, + }); + + expect(consoleMock.log.mock.calls).toEqual([]); + requestIdleCallbackMockFn.mockClear(); + transactionCallbacks.shift()!(); // Run the first transaction + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + }); + + it('should log transactions synchronously when requestIdleCallbackTimeoutMs is -1', () => { + store = createLoggedStore(store, { + ...defaultOptions, + requestIdleCallbackTimeoutMs: -1, + }); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + store.get(testAtom); + + expect(consoleMock.log.mock.calls).toEqual([]); + + vi.advanceTimersByTime(250); // Default debounce time + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should log transactions synchronously when requestIdleCallbackTimeoutMs and transactionDebounceMs are -1', () => { + store = createLoggedStore(store, { + ...defaultOptions, + requestIdleCallbackTimeoutMs: -1, + transactionDebounceMs: -1, + }); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + store.get(testAtom); + + // vi.runAllTimers(); // No need to run timers, it should log synchronously + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + }); + + describe('maxProcessingTimeMs', () => { + it('should process and log transactions in chunks when processing takes too long by default', () => { + const performanceNowSpy = vi.spyOn(performance, 'now').mockReturnValue(0); + + let callCount = 0; + performanceNowSpy.mockImplementation(() => { + callCount++; + return callCount === 1 ? 0 : 100; // First call: 0ms (start), second call: 100ms (exceeded) + }); + + const requestIdleCallbacks: (() => void)[] = []; // Store scheduled callbacks + const requestIdleCallbackMockFn = vi.fn().mockImplementation((cb: IdleRequestCallback) => { + requestIdleCallbacks.push(() => { + cb({ didTimeout: false, timeRemaining: () => 50 }); + }); + return 1; + }); + globalThis.requestIdleCallback = requestIdleCallbackMockFn; + onTestFinished(() => { + delete (globalThis as Partial).requestIdleCallback; + }); + + store = createLoggedStore(store, { + ...defaultOptions, + }); + + // Create 12 atoms : 10 will be logged in the first chunk, 2 in the second chunk + const testAtoms = Array.from({ length: 12 }, (_, i) => atom(i + 1)); + for (const testAtom of testAtoms) { + store.get(testAtom); + } + + vi.runAllTimers(); + + // Waiting for requestIdleCallback + expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); + // performance.now is always called now for start/end timestamps + expect(consoleMock.log.mock.calls).toEqual([]); + requestIdleCallbackMockFn.mockClear(); + performanceNowSpy.mockClear(); + consoleMock.log.mockClear(); + // Reset callCount so the scheduler's first call returns 0ms, second returns 100ms + callCount = 0; + + requestIdleCallbacks.shift()!(); // Invoke the 1st scheduled callback + + expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); // Called again due to time limit + expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Start + first check + expect(consoleMock.log.mock.calls).toEqual( + Array.from({ length: 10 }, (_, i) => [ + [`transaction ${i + 1} : retrieved value of ${testAtoms[i]}`], + [`initialized value of ${testAtoms[i]} to ${i + 1}`, { value: i + 1 }], + ]).flat(1), + ); + requestIdleCallbackMockFn.mockClear(); + performanceNowSpy.mockClear(); + consoleMock.log.mockClear(); + + requestIdleCallbacks.shift()!(); // Invoke the 2nd scheduled callback + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); // Finished processing + expect(performanceNowSpy).toHaveBeenCalledTimes(1); // Start only (not reached checkTimeInterval) + expect(consoleMock.log.mock.calls).toEqual( + Array.from({ length: 2 }, (_, i) => [ + [`transaction ${i + 11} : retrieved value of ${testAtoms[i + 10]}`], + [`initialized value of ${testAtoms[i + 10]} to ${i + 11}`, { value: i + 11 }], + ]).flat(1), + ); + }); + }); +}); diff --git a/tests/atom-logger-options-auto-align.test.ts b/tests/atom-logger-options-auto-align.test.ts new file mode 100644 index 0000000..e6896b0 --- /dev/null +++ b/tests/atom-logger-options-auto-align.test.ts @@ -0,0 +1,329 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.autoAlignTransactions', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should automatically align transaction components when autoAlignTransactions is enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionNumber: true, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + autoAlignTransactions: true, + }), + }); + + // transaction 1 - 1 event - 1.00 ms + const atom1 = atom(() => { + vi.advanceTimersByTime(1); + return 0; + }); + store.get(atom1); + vi.runAllTimers(); + + // transaction 2 - 2 events - 123.00 ms + const atom2 = atom(() => { + vi.advanceTimersByTime(123); + return 0; + }); + const atom3 = atom((get) => get(atom2)); + store.get(atom3); + vi.runAllTimers(); + + // transaction 3 - 12 events - 10.00 ms + const atom4 = atom(() => { + vi.advanceTimersByTime(10); + return 0; + }); + const atoms = Array.from({ length: 10 }, () => atom(0)); + const atom5 = atom((get) => atoms.reduce((sum, a) => sum + get(a), get(atom4))); + store.get(atom5); + vi.runAllTimers(); + + // transaction 4 - 1 event - 11.11 ms + const atom6 = atom(() => { + vi.advanceTimersByTime(11.11); + return 0; + }); + store.get(atom6); + vi.runAllTimers(); + + // transaction 5 - 2 events - 1.00 ms + const atom7 = atom(() => { + vi.advanceTimersByTime(1); + return 0; + }); + const atom8 = atom((get) => get(atom7)); + store.get(atom8); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 1 event - 1.00 ms : retrieved value of ${atom1}`], + // ^-- "s" padding + [`initialized value of ${atom1} to 0`, { value: 0 }], + + [`transaction 2 - 2 events - 123.00 ms : retrieved value of ${atom3}`], + [`initialized value of ${atom2} to 0`, { value: 0 }], + [`initialized value of ${atom3} to 0`, { value: 0, dependencies: [`${atom2}`] }], + + // v-- align + [`transaction 3 - 12 events - 10.00 ms : retrieved value of ${atom5}`], + [`initialized value of ${atom4} to 0`, { value: 0 }], + ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), + [ + `initialized value of ${atom5} to 0`, + { + value: 0, + dependencies: [`${atom4}`, ...atoms.map((a) => `${a}`)], + }, + ], + + // v---- align ----v + [`transaction 4 - 1 event - 11.11 ms : retrieved value of ${atom6}`], + // ^-- "s" padding + [`initialized value of ${atom6} to 0`, { value: 0 }], + + // v---- align ---v + [`transaction 5 - 2 events - 1.00 ms : retrieved value of ${atom8}`], + [`initialized value of ${atom7} to 0`, { value: 0 }], + [`initialized value of ${atom8} to 0`, { value: 0, dependencies: [`${atom7}`] }], + ]); + }); + + it('should align left events count when autoAlignTransactions is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionNumber: false, + showTransactionEventsCount: true, + autoAlignTransactions: true, + }), + }); + + // 1 event + const atom1 = atom(0); + store.get(atom1); + vi.runAllTimers(); + + // 2 events + const atom2 = atom(0); + const atom3 = atom((get) => get(atom2)); + store.get(atom3); + vi.runAllTimers(); + + // 11 events + const atoms = Array.from({ length: 10 }, () => atom(0)); + const atom4 = atom((get) => atoms.reduce((sum, a) => sum + get(a), 0)); + store.get(atom4); + vi.runAllTimers(); + + // 1 event + const atom5 = atom(0); + store.get(atom5); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`1 event : retrieved value of ${atom1}`], + // ^-- "s" padding + [`initialized value of ${atom1} to 0`, { value: 0 }], + + [`2 events : retrieved value of ${atom3}`], + [`initialized value of ${atom2} to 0`, { value: 0 }], + [`initialized value of ${atom3} to 0`, { value: 0, dependencies: [`${atom2}`] }], + + [`11 events : retrieved value of ${atom4}`], + ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), + [`initialized value of ${atom4} to 0`, { value: 0, dependencies: atoms.map((a) => `${a}`) }], + // v-- align + [`1 event : retrieved value of ${atom5}`], + // ^-- "s" padding + [`initialized value of ${atom5} to 0`, { value: 0 }], + ]); + }); + + it('should align left elapsed time when autoAlignTransactions is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionNumber: false, + showTransactionEventsCount: false, + showTransactionLocaleTime: false, + showTransactionElapsedTime: true, + autoAlignTransactions: true, + }), + }); + + // Short elapsed time + const atom1 = atom(() => { + vi.advanceTimersByTime(5.5); + return 0; + }); + store.get(atom1); + vi.runAllTimers(); + + // Longer elapsed time + const atom2 = atom(() => { + vi.advanceTimersByTime(123.45); + return 0; + }); + store.get(atom2); + vi.runAllTimers(); + + // Short elapsed time again + const atom3 = atom(() => { + vi.advanceTimersByTime(7.89); + return 0; + }); + store.get(atom3); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`5.50 ms : retrieved value of ${atom1}`], + [`initialized value of ${atom1} to 0`, { value: 0 }], + + [`123.45 ms : retrieved value of ${atom2}`], + [`initialized value of ${atom2} to 0`, { value: 0 }], + // v-- align + [`7.89 ms : retrieved value of ${atom3}`], + [`initialized value of ${atom3} to 0`, { value: 0 }], + ]); + }); + + it('should log zero elapsed time when autoAlignTransactions is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + autoAlignTransactions: true, + }), + }); + + const atom1 = atom(() => { + vi.advanceTimersByTime(1234); + return 0; + }); + store.get(atom1); + vi.runAllTimers(); + + const atom2 = atom(0); + store.get(atom2); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 1234.00 ms : retrieved value of ${atom1}`], + [`initialized value of ${atom1} to 0`, { value: 0 }], + + // v-- still present to align with previous transaction + [`transaction 2 - 0.00 ms : retrieved value of ${atom2}`], + [`initialized value of ${atom2} to 0`, { value: 0 }], + ]); + }); + + it('should not apply alignment when autoAlignTransactions is disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + autoAlignTransactions: false, + }), + }); + + // 12 events + const atom1 = atom(() => { + vi.advanceTimersByTime(1234); + return 0; + }); + const atoms = Array.from({ length: 11 }, () => atom(0)); + const atom2 = atom((get) => atoms.reduce((sum, a) => sum + get(a), get(atom1))); + store.get(atom2); + vi.runAllTimers(); + + // 1 event + const atom3 = atom(() => { + vi.advanceTimersByTime(1); + return 0; + }); + store.get(atom3); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 13 events - 1234.00 ms : retrieved value of ${atom2}`], + [`initialized value of ${atom1} to 0`, { value: 0 }], + ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), + [ + `initialized value of ${atom2} to 0`, + { value: 0, dependencies: [`${atom1}`, ...atoms.map((a) => `${a}`)] }, + ], + + [`transaction 2 - 1 event - 1.00 ms : retrieved value of ${atom3}`], + [`initialized value of ${atom3} to 0`, { value: 0 }], + ]); + }); +}); diff --git a/tests/atom-logger-options-domain.test.ts b/tests/atom-logger-options-domain.test.ts new file mode 100644 index 0000000..084802b --- /dev/null +++ b/tests/atom-logger-options-domain.test.ts @@ -0,0 +1,158 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.domain', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should respect custom options', () => { + const options: AtomLoggerOptions = { + enabled: false, + shouldShowPrivateAtoms: true, + }; + + store = createLoggedStore(store, options); + + // Only core options are stored in logger state + expect(options.enabled).toBe(false); + expect(options.shouldShowPrivateAtoms).toBe(true); + }); + + it('should not log domain when empty', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, domain: '' }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); + + it('should log domain when set', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, domain: 'test-domain' }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`test-domain - transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); + + it('should log domain with colors when set', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + domain: 'test-domain', + }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctest-domain %c- %ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // test-domain + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c42`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 42 + { value: 42 }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-options-elapsed-time.test.ts b/tests/atom-logger-options-elapsed-time.test.ts new file mode 100644 index 0000000..2cc3d63 --- /dev/null +++ b/tests/atom-logger-options-elapsed-time.test.ts @@ -0,0 +1,207 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.showTransactionElapsedTime', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log elapsed time when showTransactionElapsedTime is enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(123); // Fake the delay of the transaction + return 0; + }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 123.00 ms : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not log elapsed time when showTransactionElapsedTime is disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not log elapsed time if endTimestamp is equal or less than startTimestamp', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(0); // No delay here (with fake timers) + return 0; + }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should call performance.now when showTransactionElapsedTime is enabled', () => { + const performanceNowSpy = vi.spyOn(performance, 'now'); + + store = createLoggedStore(store, { + synchronous: true, + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + showTransactionLocaleTime: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Called at the start and the end of the transaction + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should call performance.now when showTransactionLocaleTime is enabled', () => { + const performanceNowSpy = vi.spyOn(performance, 'now'); + + store = createLoggedStore(store, { + synchronous: true, + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: false, + showTransactionLocaleTime: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Called at the start and the end of the transaction + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 00:00:00 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not call performance.now when showTransactionElapsedTime and showTransactionLocaleTime are disabled', () => { + const performanceNowSpy = vi.spyOn(performance, 'now'); + + store = createLoggedStore(store, { + synchronous: true, + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + // performance.now is now always called to record start/end timestamps for transactions, + // regardless of formatter display options + expect(performanceNowSpy).toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); +}); diff --git a/tests/atom-logger-options-enabled.test.ts b/tests/atom-logger-options-enabled.test.ts new file mode 100644 index 0000000..891be37 --- /dev/null +++ b/tests/atom-logger-options-enabled.test.ts @@ -0,0 +1,132 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.enabled', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log atom interactions when enabled', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); + + it('should not log atom interactions when disabled', () => { + store = createLoggedStore(store, { + enabled: false, + formatter: consoleFormatter({ logger: consoleMock }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log).not.toHaveBeenCalled(); + }); + + it('should not log atom interactions anymore after disabling', () => { + const options: AtomLoggerOptions = { + ...defaultOptions, + enabled: true, + }; + + store = createLoggedStore(store, options); + + const testAtom = atom(42); + + store.get(testAtom); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + + vi.clearAllMocks(); + + // Update the enabled option directly on the logger state + options.enabled = false; + + store.get(testAtom); + store.set(testAtom, 43); + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([]); + }); +}); diff --git a/tests/atom-logger-options-events-count.test.ts b/tests/atom-logger-options-events-count.test.ts new file mode 100644 index 0000000..6e1ca6e --- /dev/null +++ b/tests/atom-logger-options-events-count.test.ts @@ -0,0 +1,197 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.showTransactionEventsCount', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should show the number of events when showTransactionEventsCount is enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 1 event : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not show the number of events when showTransactionEventsCount is disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show the correct number of events for multiple events in a transaction', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: true, + }), + }); + + const atom1 = atom(0); + const atom2 = atom(0); + const derivedAtom = atom((get) => get(atom1) + get(atom2)); + + store.get(derivedAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 3 events : retrieved value of ${derivedAtom}`], + [`initialized value of ${atom1} to 0`, { value: 0 }], + [`initialized value of ${atom2} to 0`, { value: 0 }], + [ + `initialized value of ${derivedAtom} to 0`, + { value: 0, dependencies: [`${atom1}`, `${atom2}`] }, + ], + ]); + }); + + it('should show singular form for one event', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: true, + }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 1 event : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); + + it('should show events count with colors when formattedOutput is enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionEventsCount: true, + formattedOutput: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c- %c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-options-indent-spaces.test.ts b/tests/atom-logger-options-indent-spaces.test.ts new file mode 100644 index 0000000..6b131ba --- /dev/null +++ b/tests/atom-logger-options-indent-spaces.test.ts @@ -0,0 +1,162 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.indentSpaces', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should respect custom options', () => { + const options: AtomLoggerOptions = { + enabled: false, + shouldShowPrivateAtoms: true, + }; + + store = createLoggedStore(store, options); + + // Only core options are stored in logger state + expect(options.enabled).toBe(false); + expect(options.shouldShowPrivateAtoms).toBe(true); + }); + + it('should log indentation when `indentSpaces` is set to a value greater than 0', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, indentSpaces: 2 }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [` initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not log indentation when `indentSpaces` is set to 0', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, indentSpaces: 0 }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should log group indentation when `indentSpaces` is set to a value greater than 0', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + indentSpaces: 3, + groupTransactions: true, + groupEvents: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.group.mock.calls).toEqual([ + // 0 spaces + [`transaction 1 : retrieved value of ${testAtom}`], + // 3 spaces + [` initialized value of ${testAtom} to 0`], + ]); + expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); + expect(consoleMock.log.mock.calls).toEqual([ + // 6 spaces + [' value', 0], + ]); + expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); + }); + + it('should log sub-log indentation when `indentSpaces` is set and there are sub-logs', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, indentSpaces: 2 }), + }); + + const aAtom = atom(1); + const bAtom = atom((get) => get(aAtom) * 2); + store.get(bAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${bAtom}`], + [` initialized value of ${aAtom} to 1`, { value: 1 }], + [` initialized value of ${bAtom} to 2`, { dependencies: [`${aAtom}`], value: 2 }], + ]); + }); +}); diff --git a/tests/atom-logger-options-locale-time.test.ts b/tests/atom-logger-options-locale-time.test.ts new file mode 100644 index 0000000..c06218e --- /dev/null +++ b/tests/atom-logger-options-locale-time.test.ts @@ -0,0 +1,180 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.showTransactionLocaleTime', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log timestamps when showTransactionLocaleTime is enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionLocaleTime: true, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 00:00:00 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not log timestamps when showTransactionLocaleTime is disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionLocaleTime: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should log timestamps and elapsed time when both options are enabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(234); // Fake the delay of the transaction + return 0; + }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 - 00:00:00 - 234.00 ms : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should log timestamps and elapsed time with colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + formattedOutput: true, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(456); // Fake the delay of the transaction + return 0; + }); + store.get(testAtom); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c- %c00:00:00 %c- %c456.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 456.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); +}); diff --git a/tests/atom-logger-options-should-show.test.ts b/tests/atom-logger-options-should-show.test.ts new file mode 100644 index 0000000..62c10ed --- /dev/null +++ b/tests/atom-logger-options-should-show.test.ts @@ -0,0 +1,145 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; +import type { AnyAtom } from '../src/vanilla/types/event.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('shouldShowAtom', () => { + it('should respect shouldShowAtom option', () => { + const shouldShowAtom = (a: AnyAtom) => a === testAtom1; + store = createLoggedStore(store, { ...defaultOptions, shouldShowAtom }); + + const testAtom1 = atom(1); + const testAtom2 = atom(2); + + store.get(testAtom1); + vi.runAllTimers(); + + expect(consoleMock.log).toHaveBeenCalled(); + consoleMock.log.mockClear(); + + store.get(testAtom2); + vi.runAllTimers(); + + expect(consoleMock.log).not.toHaveBeenCalled(); + }); + }); + + describe('shouldShowPrivateAtoms', () => { + it('should not log private atoms by default', () => { + store = createLoggedStore(store, defaultOptions); + + const privateAtom = atom(0); + privateAtom.debugPrivate = true; + + const publicAtom = atom(1); + publicAtom.debugLabel = 'Public Atom'; + + store.get(privateAtom); + store.get(publicAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${publicAtom}`], + [`initialized value of ${publicAtom} to 1`, { value: 1 }], + ]); + }); + + it('should log private atoms when shouldShowPrivateAtoms is true', () => { + store = createLoggedStore(store, { + ...defaultOptions, + shouldShowPrivateAtoms: true, + }); + + const privateAtom = atom(0); + privateAtom.debugPrivate = true; + privateAtom.debugLabel = 'Private Atom'; + + const publicAtom = atom(1); + publicAtom.debugLabel = 'Public Atom'; + + store.get(privateAtom); + store.get(publicAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${privateAtom}`], + [`initialized value of ${privateAtom} to 0`, { value: 0 }], + + [`transaction 2 : retrieved value of ${publicAtom}`], + [`initialized value of ${publicAtom} to 1`, { value: 1 }], + ]); + }); + }); +}); diff --git a/tests/atom-logger-options-stringify.test.ts b/tests/atom-logger-options-stringify.test.ts new file mode 100644 index 0000000..4b2c66c --- /dev/null +++ b/tests/atom-logger-options-stringify.test.ts @@ -0,0 +1,433 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('stringifyLimit', () => { + it('should truncate atom values with stringifyLimit', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringifyLimit: 5 }), + }); + + const testAtom = atom({ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }); + store.get(testAtom); + store.set(testAtom, { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [ + `initialized value of ${testAtom} to {"a":…`, + { value: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }, + ], + [ + `transaction 2 : set value of ${testAtom} to {"a":…`, + { value: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }, + ], + [ + `changed value of ${testAtom} from {"a":… to {"a":…`, + { + oldValue: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }, + newValue: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }, + }, + ], + ]); + }); + + it('should truncate atom values by default', () => { + store = createLoggedStore(store, { ...defaultOptions }); + + const value = Array.from({ length: 60 }, () => 'a').join(''); + const testAtom = atom(value); + store.get(testAtom); + + vi.runAllTimers(); + + const expected = '"' + Array.from({ length: 49 }, () => 'a').join('') + '…'; + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to ${expected}`, { value: value }], + ]); + }); + + it('should not truncate atom values when stringifyLimit is 0', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringifyLimit: 0 }), + }); + + const value = Array.from({ length: 60 }, () => 'a').join(''); + const testAtom = atom(value); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to "${value}"`, { value: value }], + ]); + }); + }); + + describe('stringifyValues', () => { + const testAtom = atom({ foo: 'bar' } as unknown); + const setTestAtom = atom(null, (get, set, newValue: unknown) => { + set(testAtom, newValue); + return 'something'; + }); + + it('should stringify values when stringifyValues is true and formattedOutput is false', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: false, + stringifyValues: true, + }), + }); + + store.get(testAtom); + store.set(setTestAtom, { fizz: 'buzz' }); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to {"foo":"bar"}`, { value: { foo: 'bar' } }], + [ + `transaction 2 : called set of ${setTestAtom} with {"fizz":"buzz"} and returned "something"`, + { args: [{ fizz: 'buzz' }], result: 'something' }, + ], + [ + `changed value of ${testAtom} from {"foo":"bar"} to {"fizz":"buzz"}`, + { newValue: { fizz: 'buzz' }, oldValue: { foo: 'bar' } }, + ], + ]); + }); + + it('should stringify values with colors when stringifyValues is true and formattedOutput is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + stringifyValues: true, + }), + }); + + const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + const setTestAtomNumber = /atom(\d+)(.*)/.exec(setTestAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(setTestAtomNumber!))).toBeTruthy(); + + store.get(testAtom); + store.set(setTestAtom, { fizz: 'buzz' }); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${testAtomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${testAtomNumber} %cto %c{"foo":"bar"}`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // {"foo":"bar"} + { value: { foo: 'bar' } }, + ], + [ + `%ctransaction %c2 %c: %ccalled set %cof %catom%c${setTestAtomNumber} %cwith %c{"fizz":"buzz"} %cand returned %c"something"`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 2 + 'color: #757575; font-weight: normal;', // : + 'color: #E69F00; font-weight: bold;', // called set + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 2 + 'color: #757575; font-weight: normal;', // with + 'color: default; font-weight: normal;', // {"fizz":"buzz"} + 'color: #757575; font-weight: normal;', // and returned + 'color: default; font-weight: normal;', // "something" + { args: [{ fizz: 'buzz' }], result: 'something' }, + ], + [ + `%cchanged value %cof %catom%c${testAtomNumber} %cfrom %c{"foo":"bar"} %cto %c{"fizz":"buzz"}`, + 'color: #56B4E9; font-weight: bold;', // changed value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // from + 'color: default; font-weight: normal;', // {"foo":"bar"} + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // {"fizz":"buzz"} + { newValue: { fizz: 'buzz' }, oldValue: { foo: 'bar' } }, + ], + ]); + }); + + it('should log values as is when stringifyValues is false and formattedOutput is false', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: false, + stringifyValues: false, + }), + }); + + store.get(testAtom); + store.set(setTestAtom, { fizz: 'buzz' }); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to`, { foo: 'bar' }], + [ + `transaction 2 : called set of ${setTestAtom} with`, + { fizz: 'buzz' }, + `and returned something`, + ], + [`changed value of ${testAtom} from`, { foo: 'bar' }, `to`, { fizz: 'buzz' }], + ]); + }); + + it('should log values using string substitution and colors when stringifyValues is false and formattedOutput is true', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + stringifyValues: false, + }), + }); + + const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + const setTestAtomNumber = /atom(\d+)(.*)/.exec(setTestAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(setTestAtomNumber!))).toBeTruthy(); + + store.get(testAtom); + store.set(setTestAtom, { fizz: 'buzz' }); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${testAtomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + ], + [ + `%cinitialized value %cof %catom%c${testAtomNumber} %cto %c%o`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // {"foo":"bar"} + { foo: 'bar' }, + ], + [ + `%ctransaction %c2 %c: %ccalled set %cof %catom%c${setTestAtomNumber} %cwith %c%o %cand returned %c%o`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 2 + 'color: #757575; font-weight: normal;', // : + 'color: #E69F00; font-weight: bold;', // called set + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 2 + 'color: #757575; font-weight: normal;', // with + 'color: default; font-weight: normal;', // {"fizz":"buzz"} + { fizz: 'buzz' }, + 'color: #757575; font-weight: normal;', // and returned + 'color: default; font-weight: normal;', // "something" + 'something', + ], + [ + `%cchanged value %cof %catom%c${testAtomNumber} %cfrom %c%o %cto %c%o`, + 'color: #56B4E9; font-weight: bold;', // changed value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // from + 'color: default; font-weight: normal;', // {"foo":"bar"} + { foo: 'bar' }, + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // {"fizz":"buzz"} + { fizz: 'buzz' }, + ], + ]); + }); + }); + + describe('stringify', () => { + it('should use stringify function when provided', () => { + const customStringify = (value: unknown) => { + if (typeof value === 'object' && value !== null) { + return JSON.stringify(value, null, 2); + } + return String(value); + }; + + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringify: customStringify }), + }); + + const testAtom = atom({ foo: 'bar' }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to {\n "foo": "bar"\n}`, { value: { foo: 'bar' } }], + ]); + }); + + it('should catch errors of the custom stringify function', () => { + const customStringify = () => { + throw new Error('Custom stringify error'); + }; + + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, stringify: customStringify }), + }); + + const testAtom = atom({ foo: 'bar' }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to [Unknown]`, { value: { foo: 'bar' } }], + ]); + }); + + it('should truncate values when using custom stringify function', () => { + const customStringify = String; + + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + stringify: customStringify, + stringifyLimit: 5, + }), + }); + + const testAtom = atom('1234567890'); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 12345…`, { value: '1234567890' }], + ]); + }); + + it("should not crash if stringify doesn't returns a string", () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + stringify: () => ({ foo: 'bar' }) as unknown as string, + }), + }); + + const testAtom = atom(42); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to [Unknown]`, { value: 42 }], + ]); + }); + }); +}); diff --git a/tests/atom-logger-options-synchronous.test.ts b/tests/atom-logger-options-synchronous.test.ts new file mode 100644 index 0000000..72ee360 --- /dev/null +++ b/tests/atom-logger-options-synchronous.test.ts @@ -0,0 +1,141 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.synchronous', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log asynchronously by default', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + store.get(testAtom); + store.set(testAtom, 43); + + expect(consoleMock.log.mock.calls).toEqual([]); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], + [`changed value of ${testAtom} from 42 to 43`, { oldValue: 42, newValue: 43 }], + ]); + }); + + it('should log synchronously when synchronous is true', () => { + store = createLoggedStore(store, { ...defaultOptions, synchronous: true }); + + const testAtom = atom(42); + store.get(testAtom); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + + consoleMock.log.mockClear(); + store.set(testAtom, 43); + + // vi.runAllTimers(); // No need to run timers, it should log synchronously + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], + [`changed value of ${testAtom} from 42 to 43`, { oldValue: 42, newValue: 43 }], + ]); + }); + + it('should ignore transactionDebounceMs, requestIdleCallbackTimeoutMs and maxProcessingTimeMs options when synchronous is true', () => { + const options: AtomLoggerOptions = { + ...defaultOptions, + synchronous: true, + requestIdleCallbackTimeoutMs: 345, + transactionDebounceMs: 456, + maxProcessingTimeMs: 789, + }; + + store = createLoggedStore(store, options); + + // Values are kept but are ignored + expect(options).toEqual( + expect.objectContaining({ + synchronous: true, + requestIdleCallbackTimeoutMs: 345, + transactionDebounceMs: 456, + maxProcessingTimeMs: 789, + }), + ); + + // Mutating synchronous at runtime also works + options.synchronous = false; + expect(options.synchronous).toBe(false); + }); +}); diff --git a/tests/atom-logger-options-transaction-number.test.ts b/tests/atom-logger-options-transaction-number.test.ts new file mode 100644 index 0000000..def0055 --- /dev/null +++ b/tests/atom-logger-options-transaction-number.test.ts @@ -0,0 +1,107 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options.showTransactionNumber', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should not log transaction numbers when showTransactionNumber is disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, showTransactionNumber: false }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not log transaction when showTransactionNumber, showTransactionElapsedTime and showTransactionLocaleTime are disabled', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionNumber: false, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + }), + }); + + const testAtom = atom(0); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }); +}); diff --git a/tests/atom-logger-options.test.ts b/tests/atom-logger-options.test.ts new file mode 100644 index 0000000..ab2c052 --- /dev/null +++ b/tests/atom-logger-options.test.ts @@ -0,0 +1,47 @@ +import { createStore } from 'jotai/vanilla'; +import { type MockInstance, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('options', () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should respect custom options', () => { + const options: AtomLoggerOptions = { + enabled: false, + shouldShowPrivateAtoms: true, + }; + + store = createLoggedStore(store, options); + + // Only core options are stored in logger state + expect(options.enabled).toBe(false); + expect(options.shouldShowPrivateAtoms).toBe(true); + }); +}); diff --git a/tests/atom-logger-promises.test.ts b/tests/atom-logger-promises.test.ts new file mode 100644 index 0000000..52020fb --- /dev/null +++ b/tests/atom-logger-promises.test.ts @@ -0,0 +1,1065 @@ +import { atom } from 'jotai'; +import { atomFamily } from 'jotai-family'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('promises', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log promise states', async () => { + store = createLoggedStore(store, defaultOptions); + + const promiseAtom = atom(() => { + return new Promise((resolve) => + setTimeout(() => { + resolve(42); + }, 0), + ); + }); + + const otherPromiseAtom = atom(() => { + return new Promise((resolve, reject) => + setTimeout(() => { + reject(new Error('Promise rejected')); + }, 0), + ); + }); + + void store.get(promiseAtom); + + await vi.advanceTimersByTimeAsync(1000); + + void store.get(otherPromiseAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [`resolved initial promise of ${promiseAtom} to 42`, { value: 42 }], + + [`transaction 2 : retrieved value of ${otherPromiseAtom}`], + [`pending initial promise of ${otherPromiseAtom}`], + [ + `rejected initial promise of ${otherPromiseAtom} to Error: Promise rejected`, + { error: new Error('Promise rejected') }, + ], + ]); + }); + + it('should log rejected promises', async () => { + store = createLoggedStore(store, defaultOptions); + + const myError = new Error('Promise rejected'); + const promiseAtom = atom(() => { + return new Promise((_, reject) => + setTimeout(() => { + reject(myError); + }, 1000), + ); + }); + + const promise = store.get(promiseAtom); + + await vi.advanceTimersByTimeAsync(2000); + + await expect(promise).rejects.toThrow('Promise rejected'); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + + [`transaction 2 : rejected promise of ${promiseAtom}`], + [`rejected initial promise of ${promiseAtom} to Error: Promise rejected`, { error: myError }], + ]); + }); + + it('should show promise resolved and rejected in the same transaction if they resolve before the debounce', async () => { + store = createLoggedStore(store, defaultOptions); + + const instantPromiseAtom = atom(() => { + return new Promise((resolve) => + setTimeout(() => { + resolve(42); + }, 0), + ); + }); + + const instantPromiseRejectedAtom = atom(() => { + return new Promise((_, reject) => + setTimeout(() => { + reject(new Error('Promise rejected')); + }, 0), + ); + }); + + const slowerPromiseAtom = atom(() => { + return new Promise((resolve) => + setTimeout(() => { + resolve(42); + }, 1000), + ); + }); + + const slowerPromiseRejectedAtom = atom(() => { + return new Promise((resolve, reject) => + setTimeout(() => { + reject(new Error('Promise rejected')); + }, 1000), + ); + }); + + void store.get(instantPromiseAtom); + await vi.advanceTimersByTimeAsync(200); + + void store.get(instantPromiseRejectedAtom); + await vi.advanceTimersByTimeAsync(200); + + void store.get(slowerPromiseAtom); + void store.get(slowerPromiseRejectedAtom); + + await vi.advanceTimersByTimeAsync(2000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${instantPromiseAtom}`], + [`pending initial promise of ${instantPromiseAtom}`], + [`resolved initial promise of ${instantPromiseAtom} to 42`, { value: 42 }], // In first transaction + + [`transaction 2 : retrieved value of ${instantPromiseRejectedAtom}`], + [`pending initial promise of ${instantPromiseRejectedAtom}`], + [ + `rejected initial promise of ${instantPromiseRejectedAtom} to Error: Promise rejected`, // In second transaction + { error: new Error('Promise rejected') }, + ], + + [`transaction 3 : retrieved value of ${slowerPromiseAtom}`], + [`pending initial promise of ${slowerPromiseAtom}`], + + [`transaction 4 : retrieved value of ${slowerPromiseRejectedAtom}`], + [`pending initial promise of ${slowerPromiseRejectedAtom}`], + + [`transaction 5 : resolved promise of ${slowerPromiseAtom}`], // In another transaction + [`resolved initial promise of ${slowerPromiseAtom} to 42`, { value: 42 }], + [ + `rejected initial promise of ${slowerPromiseRejectedAtom} to Error: Promise rejected`, + { error: new Error('Promise rejected') }, + ], + ]); + }); + + it('should show promise resolved in the same transaction if they are waiting for the same async dependency', async () => { + store = createLoggedStore(store, defaultOptions); + + const otherAtom = atom(0); + const doGetOtherAtom = () => { + store.set(otherAtom, (prev) => prev + 1); // Should not be merged with the previous transaction + }; + + const dep = atom>(async () => { + return new Promise((resolve) => + setTimeout(() => { + resolve(42); + + doGetOtherAtom(); // Should not be merged BEFORE the promise transaction + setTimeout(() => { + doGetOtherAtom(); // Should not be merged AFTER the promise transaction + }, 0); + }, 1000), + ); + }); + + const prom = atomFamily((id: string) => + atom((get) => { + const dependency = get(dep); + if (dependency instanceof Promise) { + return dependency; + } + return `${id}:${dependency}`; + }), + ); + + void store.get(prom('1')); + void store.get(prom('2')); + void store.get(prom('3')); + + await vi.advanceTimersByTimeAsync(2000); + + expect(consoleMock.log.mock.calls).toEqual([ + // All pending + [`transaction 1 : retrieved value of ${prom('1')}`], + [`pending initial promise of ${dep}`, { pendingPromises: [`${prom('1')}`] }], + [`pending initial promise of ${prom('1')}`, { dependencies: [`${dep}`] }], + [`transaction 2 : retrieved value of ${prom('2')}`], + [`pending initial promise of ${prom('2')}`, { dependencies: [`${dep}`] }], + [`transaction 3 : retrieved value of ${prom('3')}`], + [`pending initial promise of ${prom('3')}`, { dependencies: [`${dep}`] }], + + // Other atom in another transaction + [`transaction 4 : set value of ${otherAtom}`], + [`initialized value of ${otherAtom} to 0`, { value: 0 }], + [`changed value of ${otherAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], + + // All resolved + [`transaction 5 : resolved promise of ${dep}`], + [ + `resolved initial promise of ${dep} to 42`, + { pendingPromises: [`${prom('1')}`, `${prom('2')}`, `${prom('3')}`], value: 42 }, + ], + [`resolved initial promise of ${prom('1')} to 42`, { dependencies: [`${dep}`], value: 42 }], + [`resolved initial promise of ${prom('2')} to 42`, { dependencies: [`${dep}`], value: 42 }], + [`resolved initial promise of ${prom('3')} to 42`, { dependencies: [`${dep}`], value: 42 }], + + // Other atom in another transaction + [`transaction 6 : set value of ${otherAtom}`], + [`changed value of ${otherAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], + ]); + }); + + it('should log aborted promises', async () => { + store = createLoggedStore(store, defaultOptions); + + const dependencyAtom = atom('first'); + const promiseAtom = atom(async (get, { signal }) => { + const dependency = get(dependencyAtom); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve(dependency); + }, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + const beforePromise = store.get(promiseAtom); + + await vi.advanceTimersByTimeAsync(250); + + store.set(dependencyAtom, 'second'); // Change the dependency before the promise resolves + + const afterPromise = store.get(promiseAtom); + + await vi.advanceTimersByTimeAsync(1500); + + await expect(beforePromise).rejects.toEqual(new Error('Promise aborted')); + await expect(afterPromise).resolves.toBe('second'); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${promiseAtom}`], + [ + `initialized value of ${dependencyAtom} to "first"`, + { pendingPromises: [`${promiseAtom}`], value: 'first' }, + ], + [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], + + [`transaction 2 : set value of ${dependencyAtom} to "second"`, { value: 'second' }], + [ + `changed value of ${dependencyAtom} from "first" to "second"`, + { + newValue: 'second', + oldValue: 'first', + pendingPromises: [`${promiseAtom}`], + }, + ], + [`aborted initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], + // This is still logged as the "initial" promise since it was aborted + [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], + + [`transaction 3 : resolved promise of ${promiseAtom}`], + [ + `resolved initial promise of ${promiseAtom} to "second"`, + { dependencies: [`${dependencyAtom}`], value: 'second' }, + ], + ]); + }); + + it('should log atom promise changes', async () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(0); + + store.sub(testAtom, vi.fn()); + + // initial promise resolved + const promise1 = Promise.resolve(1); + store.set(testAtom, promise1); + await vi.advanceTimersByTimeAsync(0); + + // changed promise resolved + const promise2 = Promise.resolve(2); + store.set(testAtom, promise2); + await vi.advanceTimersByTimeAsync(0); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + [`mounted ${testAtom}`, { value: 0 }], + + [`transaction 2 : set value of ${testAtom} to [object Promise]`, { value: promise1 }], + [`pending promise of ${testAtom} from 0`, { oldValue: 0 }], + [`resolved promise of ${testAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], + + [`transaction 3 : set value of ${testAtom} to [object Promise]`, { value: promise2 }], + [`pending promise of ${testAtom} from 1`, { oldValue: 1 }], + [`resolved promise of ${testAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], + ]); + }); + + it('should show initial promise aborted before a new promise is pending', async () => { + store = createLoggedStore(store, defaultOptions); + + const dependencyAtom = atom(0); + dependencyAtom.debugPrivate = true; + + const promiseAtom = atom(async (get, { signal }) => { + const dependency = get(dependencyAtom); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve(dependency); + }, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + store.sub(promiseAtom, vi.fn()); + + // Initial promise aborted + await vi.advanceTimersByTimeAsync(250); + store.set(dependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves + await vi.advanceTimersByTimeAsync(1500); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [`mounted ${promiseAtom}`], + [`transaction 2`], + [`aborted initial promise of ${promiseAtom}`], // Must be before pending + [`pending initial promise of ${promiseAtom}`], + [`transaction 3 : resolved promise of ${promiseAtom}`], + [`resolved initial promise of ${promiseAtom} to 1`, { value: 1 }], + ]); + }); + + it('should not log promise resolved when promise was already aborted', async () => { + // Covers the isAborted=true branch in the .then() callback + store = createLoggedStore(store, defaultOptions); + + let externalResolve: (value: number) => void; + const promiseAtom = atom(async (get, { signal }) => { + return new Promise((resolve, reject) => { + externalResolve = resolve; + signal.addEventListener('abort', () => { + reject(new Error('aborted')); + }); + }); + }); + + const dependencyAtom = atom(0); + dependencyAtom.debugPrivate = true; + + const derivedAtom = atom(async (get) => { + const dep = get(dependencyAtom); + if (dep === 0) { + return get(promiseAtom); + } + return -1; + }); + + store.sub(derivedAtom, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); + + vi.clearAllMocks(); + + // Abort by changing the dependency, then resolve the original promise + store.set(dependencyAtom, 1); + await vi.advanceTimersByTimeAsync(250); + + // Now resolve the already-aborted promise — should NOT be logged + externalResolve!(42); + await vi.advanceTimersByTimeAsync(250); + + vi.runAllTimers(); + + // The aborted promise's resolve callback fires but isAborted=true so nothing extra is logged + expect(consoleMock.log.mock.calls).not.toContain( + expect.arrayContaining([expect.stringContaining('resolved initial promise')]), + ); + }); + + it('should show changed promise aborted before a new promise is pending', async () => { + store = createLoggedStore(store, defaultOptions); + + const dependencyAtom = atom(0); + dependencyAtom.debugPrivate = true; + + const promiseAtom = atom(async (get, { signal }) => { + const dependency = get(dependencyAtom); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve(dependency); + }, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + store.sub(promiseAtom, vi.fn()); + + // Initial promise resolved + await vi.advanceTimersByTimeAsync(1250); + + // Changed promise aborted + store.set(dependencyAtom, (prev) => prev + 1); + await vi.advanceTimersByTimeAsync(250); + store.set(dependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves + await vi.advanceTimersByTimeAsync(1500); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [`mounted ${promiseAtom}`], + + [`transaction 2 : resolved promise of ${promiseAtom}`], + [`resolved initial promise of ${promiseAtom} to 0`, { value: 0 }], + + [`transaction 3`], + [`pending promise of ${promiseAtom} from 0`, { oldValue: 0 }], + + [`transaction 4`], + [`aborted promise of ${promiseAtom} from 0`, { oldValue: 0 }], // Must be before pending + [`pending promise of ${promiseAtom} from 0`, { oldValue: 0 }], + + [`transaction 5 : resolved promise of ${promiseAtom}`], + [`resolved promise of ${promiseAtom} from 0 to 2`, { oldValue: 0, newValue: 2 }], + ]); + }); + + it('should not swap events when abort is the only event in the transaction', async () => { + // Covers add-event-to-transaction.ts:152 false branch: events.length <= 1 + store = createLoggedStore(store, defaultOptions); + + const promiseAtom = atom(0); + + store.sub(promiseAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(0); + + vi.clearAllMocks(); + + // Set a promise that will be aborted — then immediately abort it by setting another value + const neverResolve = new Promise(() => {}); + store.set(promiseAtom, neverResolve); + + // Set a new value immediately so the pending promise has no time to accumulate other events, + // and the abort fires alone in its transaction + store.set(promiseAtom, 99); + + vi.runAllTimers(); + + // Main thing is no crash — the logger handles abort-as-only-event gracefully + expect(consoleMock.log.mock.calls.length).toBeGreaterThanOrEqual(0); + }); + + it('should not swap events when the event before abort is not a pending promise event', async () => { + // Covers add-event-to-transaction.ts:154 false branch: preceding event is not pending + store = createLoggedStore(store, defaultOptions); + + const aAtom = atom(1); + const bAtom = atom(2); + const depAtom = atom(0); + depAtom.debugPrivate = true; + + // A derived atom that reads both aAtom and bAtom and depends on depAtom + const promiseAtom = atom(async (get, { signal }) => { + get(depAtom); + const a = get(aAtom); + const b = get(bAtom); + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + resolve(a + b); + }, 1000); + signal.addEventListener('abort', () => { + clearTimeout(t); + reject(new Error('aborted')); + }); + }); + }); + + store.sub(promiseAtom, vi.fn()); + // Let the initial promise start + await vi.advanceTimersByTimeAsync(100); + + vi.clearAllMocks(); + + // Change aAtom (adds a changed value event for aAtom into the transaction) + // then immediately change depAtom to abort the promise + // This should result in a transaction where the event before the abort + // is a changed-value event (not a pending), so no swap should happen + store.set(aAtom, 2); + store.set(depAtom, 1); + await vi.advanceTimersByTimeAsync(1500); + + vi.runAllTimers(); + + // No crash; events logged in some order + expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); + }); + + it('should log promises in colors', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const refreshPromisesAtom = atom(0); + refreshPromisesAtom.debugPrivate = true; + + const promiseResolvedAtom = atom(async (get) => { + get(refreshPromisesAtom); + return Promise.resolve(42); + }); + const promiseRejectedAtom = atom(async (get) => { + get(refreshPromisesAtom); + return Promise.reject(new Error('Promise rejected')); + }); + + const promiseAbortedDependencyAtom = atom(0); + promiseAbortedDependencyAtom.debugPrivate = true; + const promiseAbortedAtom = atom(async (get, { signal }) => { + get(refreshPromisesAtom); + const dependency = get(promiseAbortedDependencyAtom); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (dependency <= 1) { + resolve(dependency); + } else { + reject(new Error('Rejected because of dependency higher than 1')); + } + }, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + const resolvedPromiseNumber = /atom(\d+)(.*)/.exec(promiseResolvedAtom.toString())?.[1]; + const rejectedPromiseNumber = /atom(\d+)(.*)/.exec(promiseRejectedAtom.toString())?.[1]; + const abortedPromiseNumber = /atom(\d+)(.*)/.exec(promiseAbortedAtom.toString())?.[1]; + + expect(Number.isInteger(parseInt(resolvedPromiseNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(rejectedPromiseNumber!))).toBeTruthy(); + expect(Number.isInteger(parseInt(abortedPromiseNumber!))).toBeTruthy(); + + // Initial promise resolved + store.sub(promiseResolvedAtom, vi.fn()); + + // Initial promise rejected + store.sub(promiseRejectedAtom, vi.fn()); + + // Initial promise aborted + store.sub(promiseAbortedAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + store.set(promiseAbortedDependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves + + await vi.advanceTimersByTimeAsync(1500); + + // promise resolved + // promise rejected + // promise aborted + store.set(refreshPromisesAtom, (prev) => prev + 1); + await vi.advanceTimersByTimeAsync(250); + store.set(promiseAbortedDependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves + + await vi.advanceTimersByTimeAsync(1500); + + expect(consoleMock.log.mock.calls).toEqual([ + // pending initial promise (1) + [ + `%ctransaction %c1 %c: %csubscribed %cto %catom%c${resolvedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // subscribed + `color: #757575; font-weight: normal;`, // to + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + [ + `%cpending initial promise %cof %catom%c${resolvedPromiseNumber}`, + `color: #CC79A7; font-weight: bold;`, // pending initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + [ + `%cmounted %catom%c${resolvedPromiseNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + + // pending initial promise (2) + [ + `%ctransaction %c2 %c: %csubscribed %cto %catom%c${rejectedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 2 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // subscribed + `color: #757575; font-weight: normal;`, // to + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + ], + [ + `%cpending initial promise %cof %catom%c${rejectedPromiseNumber}`, + `color: #CC79A7; font-weight: bold;`, // pending initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + ], + [ + `%cmounted %catom%c${rejectedPromiseNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + ], + + // pending initial promise (3) + [ + `%ctransaction %c3 %c: %csubscribed %cto %catom%c${abortedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // subscribed + `color: #757575; font-weight: normal;`, // to + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + [ + `%cpending initial promise %cof %catom%c${abortedPromiseNumber}`, + `color: #CC79A7; font-weight: bold;`, // pending initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + [ + `%cmounted %catom%c${abortedPromiseNumber}`, + `color: #009E73; font-weight: bold;`, // mounted + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + + // resolved initial promise (1) + [ + `%ctransaction %c4 %c: %cresolved %cpromise %cof %catom%c${resolvedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 4 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // resolved + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + ], + [ + `%cresolved %cinitial promise %cof %catom%c${resolvedPromiseNumber} %cto %c42`, + `color: #009E73; font-weight: bold;`, // resolved + `color: #CC79A7; font-weight: bold;`, // initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // 42 + { value: 42 }, + ], + // rejected initial promise (2) + [ + `%crejected %cinitial promise %cof %catom%c${rejectedPromiseNumber} %cto %cError: Promise rejected`, + `color: #D55E00; font-weight: bold;`, // rejected + `color: #CC79A7; font-weight: bold;`, // initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 6 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // Error: Promise rejected + { error: new Error(`Promise rejected`) }, + ], + + // aborted initial promise (3) + [ + `%ctransaction %c5`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 5 + ], + [ + `%caborted %cinitial promise %cof %catom%c${abortedPromiseNumber}`, + `color: #D55E00; font-weight: bold;`, // aborted + `color: #CC79A7; font-weight: bold;`, // initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + [ + `%cpending initial promise %cof %catom%c${abortedPromiseNumber}`, + `color: #CC79A7; font-weight: bold;`, // pending initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + + // resolved initial promise (3) + [ + `%ctransaction %c6 %c: %cresolved %cpromise %cof %catom%c${abortedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 6 + `color: #757575; font-weight: normal;`, // : + `color: #009E73; font-weight: bold;`, // resolved + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + [ + `%cresolved %cinitial promise %cof %catom%c${abortedPromiseNumber} %cto %c1`, + `color: #009E73; font-weight: bold;`, // resolved + `color: #CC79A7; font-weight: bold;`, // initial promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // 1 + { value: 1 }, + ], + + // pending promise 1 + pending promise 2 + resolved promise 1 + rejected promise 2 + [ + `%ctransaction %c7`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 7 + ], + [ + `%cpending promise %cof %catom%c${resolvedPromiseNumber} %cfrom %c42`, + `color: #CC79A7; font-weight: bold;`, // pending promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 42 + { oldValue: 42 }, + ], + [ + `%cpending promise %cof %catom%c${rejectedPromiseNumber} %cfrom %cError: Promise rejected`, + `color: #CC79A7; font-weight: bold;`, // pending promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // Error: Promise rejected + { oldError: new Error(`Promise rejected`) }, + ], + [ + `%cpending promise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, + `color: #CC79A7; font-weight: bold;`, // pending promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 1 + { oldValue: 1 }, + ], + [ + `%cresolved %cpromise %cof %catom%c${resolvedPromiseNumber} %cfrom %c42 %cto %c42`, + `color: #009E73; font-weight: bold;`, // resolved + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 42 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // 42 + { newValue: 42, oldValue: 42 }, + ], + [ + `%crejected %cpromise %cof %catom%c${rejectedPromiseNumber} %cfrom %cError: Promise rejected %cto %cError: Promise rejected`, + `color: #D55E00; font-weight: bold;`, // rejected + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 2 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // Error: Promise rejected + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // Error: Promise rejected + { newError: new Error(`Promise rejected`), oldError: new Error(`Promise rejected`) }, + ], + + // pending promise 3 + aborted promise 3 + [ + `%ctransaction %c8`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 8 + ], + [ + `%caborted %cpromise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, + `color: #D55E00; font-weight: bold;`, // aborted + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 1 + { oldValue: 1 }, + ], + [ + `%cpending promise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, + `color: #CC79A7; font-weight: bold;`, // pending promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 1 + { oldValue: 1 }, + ], + + // rejected promise 3 + [ + `%ctransaction %c9 %c: %crejected %cpromise %cof %catom%c${abortedPromiseNumber}`, + `color: #757575; font-weight: normal;`, // transaction + `color: default; font-weight: normal;`, // 9 + `color: #757575; font-weight: normal;`, // : + `color: #D55E00; font-weight: bold;`, // rejected + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + ], + [ + `%crejected %cpromise %cof %catom%c${abortedPromiseNumber} %cfrom %c1 %cto %cError: Rejected because of dependency higher than …`, + `color: #D55E00; font-weight: bold;`, // rejected + `color: #CC79A7; font-weight: bold;`, // promise + `color: #757575; font-weight: normal;`, // of + `color: #757575; font-weight: normal;`, // atom + `color: default; font-weight: normal;`, // 3 + `color: #757575; font-weight: normal;`, // from + `color: default; font-weight: normal;`, // 1 + `color: #757575; font-weight: normal;`, // to + `color: default; font-weight: normal;`, // Error: Rejected because of dependency higher than 1 + { error: new Error(`Rejected because of dependency higher than 1`), oldValue: 1 }, + ], + ]); + }); + + it('should log aborted promise due to changing dependencies', async () => { + store = createLoggedStore(store, defaultOptions); + + const abortedFn = vi.fn(); + + const dependencyAtom = atom(0); + + const promiseAtom = atom(async (get, { signal }) => { + get(dependencyAtom); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (!signal.aborted) resolve(42); + }, 1000); + signal.addEventListener('abort', () => { + abortedFn(); + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + store.sub(promiseAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + + expect(abortedFn).not.toHaveBeenCalled(); + store.set(dependencyAtom, store.get(dependencyAtom) + 1); + expect(abortedFn).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${promiseAtom}`], + [ + `initialized value of ${dependencyAtom} to 0`, + { + value: 0, + dependents: [`${promiseAtom}`], + pendingPromises: [`${promiseAtom}`], + }, + ], + [ + `pending initial promise of ${promiseAtom}`, + { + dependencies: [`${dependencyAtom}`], + }, + ], + [ + `mounted ${dependencyAtom}`, + { + value: 0, + dependents: [`${promiseAtom}`], + pendingPromises: [`${promiseAtom}`], + }, + ], + [ + `mounted ${promiseAtom}`, + { + dependencies: [`${dependencyAtom}`], + }, + ], + + [`transaction 2 : set value of ${dependencyAtom} to 1`, { value: 1 }], + [ + `changed value of ${dependencyAtom} from 0 to 1`, + { + newValue: 1, + oldValue: 0, + dependents: [`${promiseAtom}`], + pendingPromises: [`${promiseAtom}`], + }, + ], + [ + `aborted initial promise of ${promiseAtom}`, + { + dependencies: [`${dependencyAtom}`], + }, + ], + [ + `pending initial promise of ${promiseAtom}`, + { + dependencies: [`${dependencyAtom}`], + }, + ], + + [`transaction 3 : resolved promise of ${promiseAtom}`], + [ + `resolved initial promise of ${promiseAtom} to 42`, + { + value: 42, + dependencies: [`${dependencyAtom}`], + }, + ], + ]); + }); + + it('should not log aborted promise due to unmount', async () => { + // **not** aborted is expected due to https://github.com/pmndrs/jotai/issues/2625 + + store = createLoggedStore(store, defaultOptions); + + const abortedFn = vi.fn(); + + const promiseAtom = atom(async (get, { signal }) => { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (!signal.aborted) resolve(42); + }, 1000); + signal.addEventListener('abort', () => { + abortedFn(); + clearTimeout(timeoutId); + reject(new Error('Promise aborted')); + }); + }); + }); + + const unsubscribe = store.sub(promiseAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(250); + + expect(abortedFn).not.toHaveBeenCalled(); + unsubscribe(); + expect(abortedFn).not.toHaveBeenCalled(); // not aborted is expected + + await vi.advanceTimersByTimeAsync(2000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : subscribed to ${promiseAtom}`], + [`pending initial promise of ${promiseAtom}`], + [`mounted ${promiseAtom}`], + + [`transaction 2 : unsubscribed from ${promiseAtom}`], + [`unmounted ${promiseAtom}`], + + [`transaction 3 : resolved promise of ${promiseAtom}`], + [`resolved initial promise of ${promiseAtom} to 42`, { value: 42 }], + ]); + }); +}); diff --git a/tests/atom-logger-provider.test.tsx b/tests/atom-logger-provider.test.tsx new file mode 100644 index 0000000..d0e1e15 --- /dev/null +++ b/tests/atom-logger-provider.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +import { render } from '@testing-library/react'; +import { Provider, createStore, useStore } from 'jotai'; +import { useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + AtomLoggerProvider, + getLoggedStoreOptions, + isLoggedStore, + type AtomLoggerOptions, +} from '../src/index.js'; +import type { Store } from '../src/vanilla/types/store.js'; + +describe('AtomLoggerProvider', () => { + it('should provide a logged store to children', () => { + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + , + ); + + expect(isLoggedStore(childStore!)).toBeTruthy(); + }); + + it('should use the parent store from context', () => { + const parentStore = createStore(); + + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + + + , + ); + + expect(isLoggedStore(childStore!)).toBeTruthy(); + expect(childStore).not.toBe(parentStore); + }); + + it('should not enable logging when disabled prop is passed', () => { + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + , + ); + + // Store is still a logged store but logging is disabled + expect(isLoggedStore(childStore!)).toBeTruthy(); + expect(getLoggedStoreOptions(childStore!)?.enabled).toBe(false); + }); + + it('should propagate the parent store from context', () => { + const parentStore = createStore(); + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + + + , + ); + + expect(isLoggedStore(childStore!)).toBeTruthy(); + expect(childStore).not.toBe(parentStore); + }); + + it('should apply a custom formatter when provided', () => { + const customFormatter = vi.fn(); + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + , + ); + + expect(getLoggedStoreOptions(childStore!)?.formatter).toBe(customFormatter); + }); + + it('should update logger options when props change', () => { + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + function Parent() { + const [options, setOptions] = useState({ + shouldShowPrivateAtoms: false, + synchronous: false, + }); + + useEffect(() => { + setOptions({ + shouldShowPrivateAtoms: true, + synchronous: true, + }); + }, []); + + return ( + + + + ); + } + + render(); + + expect(getLoggedStoreOptions(childStore!)).toEqual( + expect.objectContaining({ + shouldShowPrivateAtoms: true, + }), + ); + }); + + it('should recreate the logged store when the parent store changes', () => { + const stores: Store[] = []; + + function Child() { + const store = useStore(); + stores.push(store); + return null; + } + + const parentStore1 = createStore(); + const parentStore2 = createStore(); + + function Parent() { + const [parentStore, setParentStore] = useState(parentStore1); + + useEffect(() => { + setParentStore(parentStore2); + }, []); + + return ( + + + + + + ); + } + + render(); + + expect(stores.length).toBeGreaterThanOrEqual(2); + const firstLoggedStore = stores[0]!; + const lastLoggedStore = stores[stores.length - 1]!; + expect(firstLoggedStore).not.toBe(lastLoggedStore); + expect(isLoggedStore(firstLoggedStore)).toBeTruthy(); + expect(isLoggedStore(lastLoggedStore)).toBeTruthy(); + }); + + it('should use default options when none provided', () => { + let childStore: Store | undefined; + + function Child() { + childStore = useStore(); + return null; + } + + render( + + + , + ); + + expect(isLoggedStore(childStore!)).toBeTruthy(); + expect(getLoggedStoreOptions(childStore!)).toEqual( + expect.objectContaining({ + enabled: true, + shouldShowPrivateAtoms: false, + transactionDebounceMs: 250, + requestIdleCallbackTimeoutMs: 250, + maxProcessingTimeMs: 16, + synchronous: false, + }), + ); + }); + + it('should keep the parent store unmodified', () => { + const parentStore = createStore(); + const originalGet = parentStore.get; + const originalSet = parentStore.set; + const originalSub = parentStore.sub; + + render( + + +
+ + , + ); + + expect(parentStore.get).toBe(originalGet); + expect(parentStore.set).toBe(originalSet); + expect(parentStore.sub).toBe(originalSub); + expect(isLoggedStore(parentStore)).toBeFalsy(); + }); +}); diff --git a/tests/atom-logger-react-stack.test.ts b/tests/atom-logger-react-stack.test.ts new file mode 100644 index 0000000..70b90f1 --- /dev/null +++ b/tests/atom-logger-react-stack.test.ts @@ -0,0 +1,578 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('owner stack and component display name', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should show owner stack', async () => { + let stackId = 0; + + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return `at MyCounterParent${++stackId} (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(stackId).toBe(1); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [MyCounterParent1] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show component display name', async () => { + let displayNameId = 0; + + store = createLoggedStore(store, { + ...defaultOptions, + getComponentDisplayName() { + return `MyCounter${++displayNameId}`; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(displayNameId).toBe(1); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : MyCounter1 retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show owner stack and component display name', async () => { + let stackId = 0; + let displayNameId = 0; + + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return `at MyCounterParent${++stackId} (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; + }, + getComponentDisplayName() { + return `MyCounter${++displayNameId}`; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(stackId).toBe(1); + expect(displayNameId).toBe(1); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [MyCounterParent1] MyCounter1 retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not show component display name if it is shown at the end of the owner stack components', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return `at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) + at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21)`; + }, + getComponentDisplayName() { + return 'ParentComponent'; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [GrandParentComponent.ParentComponent] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show component display name if it is not shown at the end of the owner stack components', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return `at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) + at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21)`; + }, + getComponentDisplayName() { + return 'GrandParentComponent'; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `transaction 1 : [GrandParentComponent.ParentComponent] GrandParentComponent retrieved value of ${countAtom}`, + ], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should ignore crashes in getOwnerStack', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack: () => { + throw new Error('Error in getOwnerStack'); + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should ignore crashes in getComponentDisplayName', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getComponentDisplayName: () => { + throw new Error('Error in getComponentDisplayName'); + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should ignore undefined owner stack traces', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack: () => { + return undefined; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should ignore null owner stack traces', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack: () => { + return null; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should handle malformed owner stack gracefully', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return `malformed stack trace without proper format`; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should handle empty owner stack', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return ''; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should not call getOwnerStack if not needed', async () => { + const getOwnerStackMock = vi.fn(() => { + return `at MyCounterParent (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; + }); + + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack: getOwnerStackMock, + }); + + const countAtom = atom(0); + + expect(getOwnerStackMock).not.toHaveBeenCalled(); + + store.get(countAtom); // 1st call + store.get(countAtom); // should not call again (value already initialized) + store.get(countAtom); + await vi.advanceTimersByTimeAsync(1000); + + expect(getOwnerStackMock).toHaveBeenCalledTimes(1); + + const unSub1 = store.sub(countAtom, vi.fn()); // 2nd call (mounted) + const unSub2 = store.sub(countAtom, vi.fn()); + await vi.advanceTimersByTimeAsync(1000); + + expect(getOwnerStackMock).toHaveBeenCalledTimes(2); + + store.get(countAtom); + const unSub3 = store.sub(countAtom, vi.fn()); + unSub1(); + unSub2(); // still mounted + await vi.advanceTimersByTimeAsync(1000); + + expect(getOwnerStackMock).toHaveBeenCalledTimes(2); + + unSub3(); // 3rd call (unmounted) + await vi.advanceTimersByTimeAsync(1000); + + expect(getOwnerStackMock).toHaveBeenCalledTimes(3); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [MyCounterParent] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + [`transaction 2 : [MyCounterParent] subscribed to ${countAtom}`], + [`mounted ${countAtom}`, { value: 0 }], + [`transaction 3 : [MyCounterParent] unsubscribed from ${countAtom}`], + [`unmounted ${countAtom}`], + ]); + }); + + describe('ownerStackLimit', () => { + const BIG_OWNER_STACK = `at ChildComponent (http://localhost:5173/src/child.tsx:10:21) + at MiddleComponent (http://localhost:5173/src/middle.tsx:20:21) + at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) + at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21) + at RootComponent (http://localhost:5173/src/root.tsx:50:21)`; + + it('should respect ownerStackLimit default value of 2', async () => { + store = createLoggedStore(store, { + ...defaultOptions, + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [MiddleComponent.ChildComponent] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show all components when ownerStackLimit is -1', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: -1 }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `transaction 1 : [RootComponent.GrandParentComponent.ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, + ], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show all components when ownerStackLimit is Infinity', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: Infinity }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `transaction 1 : [RootComponent.GrandParentComponent.ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, + ], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show no components when ownerStackLimit is 0', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: 0 }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show only 1 component when ownerStackLimit is 1', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: 1 }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [ChildComponent] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should show limited components when ownerStackLimit is 3', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: 3 }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `transaction 1 : [ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, + ], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should handle single component in owner stack', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: 2 }), + getOwnerStack() { + return `at ChildComponent (http://localhost:5173/src/child.tsx:10:21)`; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [ChildComponent] retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should work with ownerStackLimit and component display name', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, ownerStackLimit: 1 }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + getComponentDisplayName() { + return 'CurrentComponent'; + }, + }); + + const countAtom = atom(0); + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : [ChildComponent] CurrentComponent retrieved value of ${countAtom}`], + [`initialized value of ${countAtom} to 0`, { value: 0 }], + ]); + }); + + it('should work with ownerStackLimit in colored output', async () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + ownerStackLimit: 1, + }), + getOwnerStack() { + return BIG_OWNER_STACK; + }, + getComponentDisplayName() { + return 'CurrentComponent'; + }, + }); + + const countAtom = atom(0); + const atomNumber = /atom(\d+)(.*)/.exec(countAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + store.get(countAtom); + + await vi.advanceTimersByTimeAsync(1000); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %c[ChildComponent] %cCurrentComponent %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #757575; font-weight: normal;', // [ChildComponent] + 'color: default; font-weight: normal;', // CurrentComponent + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }); + }); +}); diff --git a/tests/atom-logger-setters.test.ts b/tests/atom-logger-setters.test.ts new file mode 100644 index 0000000..b91498f --- /dev/null +++ b/tests/atom-logger-setters.test.ts @@ -0,0 +1,208 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('setters', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log default atom setter', () => { + store = createLoggedStore(store, defaultOptions); + + const simpleAtom = atom(0); + store.set(simpleAtom, 1); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : set value of ${simpleAtom} to 1`, { value: 1 }], + [`initialized value of ${simpleAtom} to 1`, { value: 1 }], + ]); + }); + + it('should log default atom setter in colors', () => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ ...defaultFormatterOptions, formattedOutput: true }), + }); + + const simpleAtom = atom(0); + store.set(simpleAtom, 1); + + const atomNumber = /atom(\d+)(.*)/.exec(simpleAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [ + `%ctransaction %c1 %c: %cset value %cof %catom%c${atomNumber} %cto %c1`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #E69F00; font-weight: bold;', // set value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 1 + { value: 1 }, + ], + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c1`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 1 + { value: 1 }, + ], + ]); + }); + + it('should log custom atom setter', () => { + store = createLoggedStore(store, defaultOptions); + + const valueAtom = atom(0); + const oneSetAtom = atom(null, (get, set) => { + set(valueAtom, 1); + }); + const twoSetAtom = atom(null, (get, set, args: { newValue: number }) => { + set(valueAtom, args.newValue); + }); + const threeSetAtom = atom(null, (get, set) => { + set(valueAtom, 3); + return `myReturnValue-3`; + }); + const fourSetAtom = atom(null, (get, set, args: { newValue: number }, otherArg: string) => { + set(valueAtom, args.newValue); + return `myOtherReturnValue-${args.newValue}-${otherArg}`; + }); + + store.get(valueAtom); + + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + const one = store.set(oneSetAtom); + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + const two = store.set(twoSetAtom, { newValue: 2 }); + const three = store.set(threeSetAtom); + const four = store.set(fourSetAtom, { newValue: 4 }, 'otherArg'); + + expect(one).toBe(undefined); + expect(two).toBe(undefined); + expect(three).toBe('myReturnValue-3'); + expect(four).toBe('myOtherReturnValue-4-otherArg'); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${valueAtom}`], + [`initialized value of ${valueAtom} to 0`, { value: 0 }], + + [`transaction 2 : called set of ${oneSetAtom}`], + [`changed value of ${valueAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], + + [ + `transaction 3 : called set of ${twoSetAtom} with {"newValue":2}`, + { args: [{ newValue: 2 }] }, + ], + [`changed value of ${valueAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], + + [ + `transaction 4 : called set of ${threeSetAtom} and returned "myReturnValue-3"`, + { result: 'myReturnValue-3' }, + ], + [`changed value of ${valueAtom} from 2 to 3`, { newValue: 3, oldValue: 2 }], + + [ + `transaction 5 : called set of ${fourSetAtom} with [{"newValue":4},"otherArg"] and returned "myOtherReturnValue-4-otherArg"`, + { + args: [{ newValue: 4 }, 'otherArg'], + result: 'myOtherReturnValue-4-otherArg', + }, + ], + [`changed value of ${valueAtom} from 3 to 4`, { newValue: 4, oldValue: 3 }], + ]); + }); + + it('should log default atom setter with previous state function', () => { + store = createLoggedStore(store, defaultOptions); + + const simpleAtom = atom(0); + store.set(simpleAtom, (prev) => prev + 1); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : set value of ${simpleAtom}`], + [`initialized value of ${simpleAtom} to 0`, { value: 0 }], + [`changed value of ${simpleAtom} from 0 to 1`, { oldValue: 0, newValue: 1 }], + ]); + }); +}); diff --git a/tests/atom-logger-store.test.ts b/tests/atom-logger-store.test.ts new file mode 100644 index 0000000..d719db6 --- /dev/null +++ b/tests/atom-logger-store.test.ts @@ -0,0 +1,403 @@ +import { atom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + INTERNAL_buildStoreRev3 as buildStore, + INTERNAL_getBuildingBlocksRev3 as getBuildingBlocks, + INTERNAL_initializeStoreHooksRev3 as initializeStoreHooks, + type INTERNAL_BuildingBlocks as BuildingBlocks, +} from 'jotai/vanilla/internals'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { + AtomEventTypes, + type AtomLoggerOptions, + AtomTransactionTypes, + createLoggedStore, +} from '../src/index.js'; +import { isLoggedStore } from '../src/vanilla/create-logged-store.js'; +import type { Store } from '../src/vanilla/types/store.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('store', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('jotai-devtools should not create a dev store when calling createStore', () => { + function isDevtoolsStore(store: Store): boolean { + return 'get_internal_weak_map' in store; + } + + // Just to be sure that the test file is not running with a devtools store + expect(isDevtoolsStore(createStore())).toBeFalsy(); + }); + + it('should create a logged store', () => { + expect(isLoggedStore(store)).toBeFalsy(); + store = createLoggedStore(store, defaultOptions); + expect(isLoggedStore(store)).toBeTruthy(); + expect(consoleMock.log.mock.calls).toEqual([]); + }); + + it('should not bind the logger to the store if the store does not contain jotai internal building blocks', () => { + const fakeStore: Store = { + get() { + throw new Error('Function not implemented.'); + }, + set() { + throw new Error('Function not implemented.'); + }, + sub() { + throw new Error('Function not implemented.'); + }, + }; + expect(() => createLoggedStore(fakeStore, defaultOptions)).toThrow( + 'Store must be created by buildStore to read its building blocks', + ); + }); + + it('should return a new store with different get/set/sub methods', () => { + const parentStore = store; + const originalGet = store.get; + const originalSet = store.set; + const originalSub = store.sub; + + store = createLoggedStore(store, defaultOptions); + + expect(store.get).not.toBe(originalGet); + expect(store.set).not.toBe(originalSet); + expect(store.sub).not.toBe(originalSub); + + // Parent store remains unmodified + expect(parentStore.get).toBe(originalGet); + expect(parentStore.set).toBe(originalSet); + expect(parentStore.sub).toBe(originalSub); + }); + + it('should log operations performed through the logged store', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(42); + + store.get(testAtom); + store.set(testAtom, 43); + const listener = vi.fn(); + store.sub(testAtom, listener); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); + }); + + it('should create an independent logged store', () => { + expect(isLoggedStore(store)).toBeFalsy(); + store = createLoggedStore(store, defaultOptions); + expect(isLoggedStore(store)).toBeTruthy(); + expect(consoleMock.log.mock.calls).toEqual([]); + }); + + it('should allow updating options on the logged store state', () => { + const options: AtomLoggerOptions = { + ...defaultOptions, + enabled: true, + }; + store = createLoggedStore(store, options); + + expect(options.enabled).toBe(true); + + options.enabled = false; + + expect(options.enabled).toBe(false); + }); + + it('should keep the existing formatter when updating options after creation', () => { + const customFormatter = vi.fn(); + const options: AtomLoggerOptions = { + formatter: customFormatter, + enabled: true, + }; + store = createLoggedStore(store, options); + + const formatterAfterCreation = options.formatter; + expect(formatterAfterCreation).toBe(customFormatter); + + // Update a core option directly, formatter should remain + options.enabled = false; + + // Formatter should remain the same instance + expect(options.formatter).toBe(customFormatter); + expect(options.enabled).toBe(false); + }); + + it('should throw when given a store without jotai internals', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const fakeStore: Store = { + get() { + throw new Error('Function not implemented.'); + }, + set() { + throw new Error('Function not implemented.'); + }, + sub() { + throw new Error('Function not implemented.'); + }, + }; + expect(() => createLoggedStore(fakeStore)).toThrow( + 'Store must be created by buildStore to read its building blocks', + ); + consoleErrorSpy.mockRestore(); + }); + + it('should return a new store with different get/set/sub methods', () => { + const parentStore = createStore(); + const loggedStore = createLoggedStore(parentStore); + + expect(loggedStore.get).not.toBe(parentStore.get); + expect(loggedStore.set).not.toBe(parentStore.set); + expect(loggedStore.sub).not.toBe(parentStore.sub); + }); + + it('should leave the parent store unmodified', () => { + const parentStore = createStore(); + const originalGet = parentStore.get; + const originalSet = parentStore.set; + const originalSub = parentStore.sub; + + createLoggedStore(parentStore); + + expect(parentStore.get).toBe(originalGet); + expect(parentStore.set).toBe(originalSet); + expect(parentStore.sub).toBe(originalSub); + expect(isLoggedStore(parentStore)).toBeFalsy(); + }); + + it('should share internal store hooks with child stores but not with the parent store', () => { + const parentStore = createStore(); + const loggedStore = createLoggedStore(parentStore); + + const loggedStoreBuildingBlocks = getBuildingBlocks(loggedStore); + const childStoreStoreHooks = initializeStoreHooks(loggedStoreBuildingBlocks[6]); + const childStoreBuildingBlocks: BuildingBlocks = [...loggedStoreBuildingBlocks]; + childStoreBuildingBlocks[6] = childStoreStoreHooks; + const childStore = buildStore(...childStoreBuildingBlocks); + + expect(isLoggedStore(parentStore)).toBeFalsy(); + expect(isLoggedStore(loggedStore)).toBeTruthy(); + expect(isLoggedStore(childStore)).toBeFalsy(); + + // The logged store and its child share the same hooks, so operations on both trigger the logger's callbacks. + expect(getBuildingBlocks(childStore)[6]).toBe(childStoreStoreHooks); + expect(getBuildingBlocks(loggedStore)[6]).toBe(childStoreStoreHooks); + + // The parent store keeps its own separate hooks so its operations never trigger logger callbacks. + expect(getBuildingBlocks(parentStore)[6]).not.toBe(childStoreStoreHooks); + }); + + it('should not log transactions performed through the parent store', () => { + const transactions: unknown[] = []; + const formatter = vi.fn((transaction) => transactions.push(transaction)); + + const parentStore = createStore(); + createLoggedStore(parentStore, { formatter, synchronous: true }); + + const testAtom = atom(0); + parentStore.set(testAtom, 1); + + expect(transactions).toEqual([]); + }); + + it('should not log transactions performed through a sibling logged store', () => { + const transactions1: unknown[] = []; + const formatter1 = vi.fn((transaction) => transactions1.push(transaction)); + const transactions2: unknown[] = []; + const formatter2 = vi.fn((transaction) => transactions2.push(transaction)); + + const parentStore = createStore(); + const loggedStore1 = createLoggedStore(parentStore, { + formatter: formatter1, + synchronous: true, + }); + createLoggedStore(parentStore, { + formatter: formatter2, + synchronous: true, + }); + + const testAtom = atom(0); + loggedStore1.set(testAtom, 1); + + expect(transactions1).toEqual([ + expect.objectContaining({ + type: AtomTransactionTypes.storeSet, + atom: testAtom, + events: [ + { + type: AtomEventTypes.initialized, + atom: testAtom, + value: 1, + }, + ], + }), + ]); + + expect(transactions2).toEqual([]); + }); + + it('should log transactions performed through a child store', () => { + const transactions: unknown[] = []; + const formatter = vi.fn((transaction) => transactions.push(transaction)); + + const parentStore = createStore(); + const loggedStore = createLoggedStore(parentStore, { formatter, synchronous: true }); + const childStore = buildStore(...getBuildingBlocks(loggedStore)); + + const testAtom = atom(0); + childStore.set(testAtom, 1); + + expect(transactions.length).toBe(1); + expect(transactions).toEqual([ + expect.objectContaining({ + type: AtomTransactionTypes.storeSet, + atom: testAtom, + events: [ + { + type: AtomEventTypes.initialized, + atom: testAtom, + value: 1, + }, + ], + }), + ]); + }); + + it('should log transactions performed through logged store but not through its parent logged store', () => { + const parentTransactions1: unknown[] = []; + const parentFormatter = vi.fn((transaction) => parentTransactions1.push(transaction)); + const childTransactions: unknown[] = []; + const childFormatter = vi.fn((transaction) => childTransactions.push(transaction)); + + const parentStore = createStore(); + const parentLoggedStore = createLoggedStore(parentStore, { + formatter: parentFormatter, + synchronous: true, + }); + const childLoggedStore = createLoggedStore(parentLoggedStore, { + formatter: childFormatter, + synchronous: true, + }); + + const testAtom = atom(0); + childLoggedStore.set(testAtom, 1); + + expect(parentTransactions1).toEqual([]); + expect(childTransactions).toEqual([ + expect.objectContaining({ + type: AtomTransactionTypes.storeSet, + atom: testAtom, + events: [ + { + type: AtomEventTypes.initialized, + atom: testAtom, + value: 1, + }, + ], + }), + ]); + }); + + it('should log transactions performed through a logged store but not through its child logged store', () => { + const parentTransactions: unknown[] = []; + const parentFormatter = vi.fn((transaction) => parentTransactions.push(transaction)); + const childTransactions: unknown[] = []; + const childFormatter = vi.fn((transaction) => childTransactions.push(transaction)); + + const parentStore = createStore(); + const parentLoggedStore = createLoggedStore(parentStore, { + formatter: parentFormatter, + synchronous: true, + }); + createLoggedStore(parentLoggedStore, { + formatter: childFormatter, + synchronous: true, + }); + + const testAtom = atom(0); + parentLoggedStore.set(testAtom, 1); + + expect(parentTransactions).toEqual([ + expect.objectContaining({ + type: AtomTransactionTypes.storeSet, + atom: testAtom, + events: [ + { + type: AtomEventTypes.initialized, + atom: testAtom, + value: 1, + }, + ], + }), + ]); + expect(childTransactions).toEqual([]); + }); +}); diff --git a/tests/atom-logger-transactions.test.ts b/tests/atom-logger-transactions.test.ts new file mode 100644 index 0000000..0d58b9e --- /dev/null +++ b/tests/atom-logger-transactions.test.ts @@ -0,0 +1,721 @@ +import { atom } from 'jotai'; +import type { PrimitiveAtom } from 'jotai'; +import { createStore } from 'jotai/vanilla'; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + it, + onTestFinished, + vi, +} from 'vitest'; + +import { consoleFormatter } from '../src/formatters/console/index.js'; +import type { ConsoleFormatterOptions } from '../src/formatters/console/types.js'; +import { type AtomLoggerOptions, createLoggedStore } from '../src/index.js'; +import type { AnyAtom } from '../src/vanilla/types/event.js'; + +let mockDate: MockInstance; + +beforeEach(() => { + vi.useFakeTimers({ now: 0 }); + vi.stubEnv('TZ', 'UTC'); + mockDate = vi + .spyOn(Date.prototype, 'toLocaleTimeString') + .mockImplementation(function toLocaleTimeStringMock(this: Date) { + return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + mockDate.mockRestore(); +}); + +describe('transactions', () => { + let store: ReturnType; + let consoleMock: { + log: Mock; + group: Mock; + groupEnd: Mock; + groupCollapsed: Mock; + }; + let defaultFormatterOptions: ConsoleFormatterOptions; + let defaultOptions: AtomLoggerOptions; + + beforeEach(() => { + store = createStore(); + consoleMock = { + log: vi.fn(), + group: vi.fn(), + groupEnd: vi.fn(), + groupCollapsed: vi.fn(), + }; + defaultFormatterOptions = { + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + showTransactionEventsCount: false, + collapseTransactions: false, + collapseEvents: false, + autoAlignTransactions: false, + }; + defaultOptions = { + formatter: consoleFormatter(defaultFormatterOptions), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should log transactions details triggered by public atoms', () => { + store = createLoggedStore(store, defaultOptions); + const publicAtom = atom(0); + const notPrivateSetAtom = atom(null, (get, set) => { + set(publicAtom, 1); + }); + store.set(notPrivateSetAtom); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : called set of ${notPrivateSetAtom}`], + [`initialized value of ${publicAtom} to 1`, { value: 1 }], + ]); + }); + + it('should not log transactions details triggered by private atoms', () => { + store = createLoggedStore(store, defaultOptions); + const publicAtom = atom(0); + const privateSetAtom = atom(null, (get, set) => { + set(publicAtom, 1); + }); + privateSetAtom.debugPrivate = true; + store.set(privateSetAtom); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${publicAtom} to 1`, { value: 1 }], + ]); + }); + + it('should not log transactions with only private atoms', () => { + store = createLoggedStore(store, { ...defaultOptions, shouldShowPrivateAtoms: false }); + const privateAtom = atom(0); + privateAtom.debugPrivate = true; + const privateSetAtom = atom(null, (get, set) => { + set(privateAtom, 1); + }); + privateSetAtom.debugPrivate = true; + store.set(privateSetAtom); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([]); + }); + + it('should not log transactions without events', () => { + store = createLoggedStore(store, defaultOptions); + const testSetAtom = atom(null, () => { + // No events + }); + store.set(testSetAtom); + vi.runAllTimers(); + expect(consoleMock.log.mock.calls).toEqual([]); + }); + + it('should log changes made outside of transactions inside an unknown transaction', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(0); + const setTestAtom = atom(null, (get, set) => { + setTimeout(() => { + set(testAtom, 42); // Outside of store.set transaction + }, 1000); + }); + store.set(setTestAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], // No transaction name since it's an unknown transaction + [`initialized value of ${testAtom} to 42`, { value: 42 }], + ]); + }); + + it('should debounce events in the same transaction', () => { + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom('trans-1.0'); + const setTestAtom = atom(null, (get, set) => { + setTimeout(() => { + // This is a new unknown transaction + set(testAtom, 'trans-1.1'); + vi.advanceTimersByTime(50); // debounce + set(testAtom, 'trans-1.2'); + vi.advanceTimersByTime(50); // debounce + set(testAtom, 'trans-1.3'); + + // Will be in another transaction if >= 250ms + vi.advanceTimersByTime(250); + set(testAtom, 'trans-2.1'); + }, 1000); + }); + store.set(setTestAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1`], + [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], + [ + `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, + { + newValue: 'trans-1.3', + oldValues: ['trans-1.1', 'trans-1.2'], + }, + ], + + [`transaction 2`], + [ + `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, + { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, + ], + ]); + }); + + describe('requestIdleCallback', () => { + it('should schedule and log queued transactions by using requestIdleCallback', () => { + const requestIdleCallbacks: (() => void)[] = []; + const requestIdleCallbackMockFn = vi.fn((cb: IdleRequestCallback) => { + requestIdleCallbacks.push(() => { + cb({ didTimeout: false, timeRemaining: () => 50 }); + }); + return 1; + }); + globalThis.requestIdleCallback = requestIdleCallbackMockFn; + onTestFinished(() => { + delete (globalThis as Partial).requestIdleCallback; + }); + + store = createLoggedStore(store, defaultOptions); + + const testAtom = atom(0); + + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); + expect(consoleMock.log.mock.calls).toEqual([]); + + // Run all transactions + store.get(testAtom); + store.set(testAtom, 1); + store.set(testAtom, 2); + vi.runAllTimers(); + expect(requestIdleCallbackMockFn).toHaveBeenCalledOnce(); // First transaction scheduled + requestIdleCallbackMockFn.mockClear(); + expect(consoleMock.log.mock.calls).toEqual([]); // Nothing logged yet + + requestIdleCallbacks.shift()!(); // Run the queued transactions + vi.runAllTimers(); + expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); // No more transactions scheduled + expect(consoleMock.log.mock.calls).toEqual([ + [`transaction 1 : retrieved value of ${testAtom}`], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + [`transaction 2 : set value of ${testAtom} to 1`, { value: 1 }], + [`changed value of ${testAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], + [`transaction 3 : set value of ${testAtom} to 2`, { value: 2 }], + [`changed value of ${testAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], + ]); + }); + }); + + it('should merge nested direct store calls', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + onTestFinished(() => { + consoleWarnSpy.mockRestore(); + }); + + store = createLoggedStore(store, defaultOptions); + + const otherAtom1 = atom(0); + const otherAtom2 = atom(0); + const otherAtom3 = atom(0); + + const testAtomCallback = (otherAtom: PrimitiveAtom) => () => { + store.get(otherAtom); // Nested store.get call + store.set(otherAtom, 2); // Nested store.set call + store.sub(otherAtom, vi.fn()); // Nested store.sub call + }; + + const testAtom1 = atom(testAtomCallback(otherAtom1), testAtomCallback(otherAtom1)); + const testAtom2 = atom(testAtomCallback(otherAtom2), testAtomCallback(otherAtom2)); + const testAtom3 = atom(testAtomCallback(otherAtom3), testAtomCallback(otherAtom3)); + + store.get(testAtom1); + store.set(testAtom2); + store.sub(testAtom3, vi.fn()); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + // Nested inside store.get + [`transaction 1 : retrieved value of ${testAtom1}`], + // `- Nested store.get transaction + [`initialized value of ${otherAtom1} to 0`, { value: 0 }], + // `- Nested store.set transaction + [`changed value of ${otherAtom1} from 0 to 2`, { newValue: 2, oldValue: 0 }], + // `- Nested store.sub transaction + [`mounted ${otherAtom1}`, { value: 2 }], + [`initialized value of ${testAtom1} to undefined`, { value: undefined }], + + // Nested inside store.set + [`transaction 2 : called set of ${testAtom2}`], + // `- Nested store.get transaction + [`initialized value of ${otherAtom2} to 0`, { value: 0 }], + // `- Nested store.set transaction + [`changed value of ${otherAtom2} from 0 to 2`, { newValue: 2, oldValue: 0 }], + // `- Nested store.sub transaction + [`mounted ${otherAtom2}`, { value: 2 }], + + // Nested inside store.sub + [`transaction 3 : subscribed to ${testAtom3}`], + // `- Nested store.get transaction + [`initialized value of ${otherAtom3} to 0`, { value: 0 }], + // `- Nested store.set transaction + [`changed value of ${otherAtom3} from 0 to 2`, { newValue: 2, oldValue: 0 }], + // `- Nested store.sub transaction + [`mounted ${otherAtom3}`, { value: 2 }], + [`initialized value of ${testAtom3} to undefined`, { value: undefined }], + [`mounted ${testAtom3}`, { value: undefined }], + ]); + + // Jotai should warns about direct store mutations inside atoms + expect(consoleWarnSpy.mock.calls).toEqual([ + ['Detected store mutation during atom read. This is not supported.'], + ['Detected store mutation during atom read. This is not supported.'], + ]); + }); + + describe('combinations of transaction options', () => { + const testCases = [ + // 0000 - all false + { + binary: '0000', + showTransactionNumber: false, + showTransactionEventsCount: false, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%cretrieved value %cof %catom%c${atomNumber}`, + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0001 - only showTransactionLocaleTime true + { + binary: '0001', + showTransactionNumber: false, + showTransactionEventsCount: false, + showTransactionElapsedTime: false, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => `00:00:00 : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0010 - only showTransactionElapsedTime true + { + binary: '0010', + showTransactionNumber: false, + showTransactionEventsCount: false, + showTransactionElapsedTime: true, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0011 - showTransactionElapsedTime and showTransactionLocaleTime true + { + binary: '0011', + showTransactionNumber: false, + showTransactionEventsCount: false, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => `00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0100 - only showTransactionEventsCount true + { + binary: '0100', + showTransactionNumber: false, + showTransactionEventsCount: true, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `1 event : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0101 - showTransactionEventsCount and showTransactionLocaleTime true + { + binary: '0101', + showTransactionNumber: false, + showTransactionEventsCount: true, + showTransactionElapsedTime: false, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => `1 event - 00:00:00 : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c1 event %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0110 - showTransactionEventsCount and showTransactionElapsedTime true + { + binary: '0110', + showTransactionNumber: false, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `1 event - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c1 event %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 0111 - showTransactionEventsCount, showTransactionElapsedTime and showTransactionLocaleTime true + { + binary: '0111', + showTransactionNumber: false, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => + `1 event - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%c1 event %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1000 - only showTransactionNumber true + { + binary: '1000', + showTransactionNumber: true, + showTransactionEventsCount: false, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `transaction 1 : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1001 - showTransactionNumber and showTransactionLocaleTime true + { + binary: '1001', + showTransactionNumber: true, + showTransactionEventsCount: false, + showTransactionElapsedTime: false, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => + `transaction 1 - 00:00:00 : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1010 - showTransactionNumber and showTransactionElapsedTime true + { + binary: '1010', + showTransactionNumber: true, + showTransactionEventsCount: false, + showTransactionElapsedTime: true, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => + `transaction 1 - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1011 - showTransactionNumber, showTransactionElapsedTime and showTransactionLocaleTime true + { + binary: '1011', + showTransactionNumber: true, + showTransactionEventsCount: false, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => + `transaction 1 - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1100 - showTransactionNumber and showTransactionEventsCount true + { + binary: '1100', + showTransactionNumber: true, + showTransactionEventsCount: true, + showTransactionElapsedTime: false, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => `transaction 1 - 1 event : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1101 - showTransactionNumber, showTransactionEventsCount and showTransactionLocaleTime true + { + binary: '1101', + showTransactionNumber: true, + showTransactionEventsCount: true, + showTransactionElapsedTime: false, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => + `transaction 1 - 1 event - 00:00:00 : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c1 event %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1110 - showTransactionNumber, showTransactionEventsCount and showTransactionElapsedTime true + { + binary: '1110', + showTransactionNumber: true, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + showTransactionLocaleTime: false, + expected: (testAtom: AnyAtom) => + `transaction 1 - 1 event - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c1 event %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + // 1111 - all true + { + binary: '1111', + showTransactionNumber: true, + showTransactionEventsCount: true, + showTransactionElapsedTime: true, + showTransactionLocaleTime: true, + expected: (testAtom: AnyAtom) => + `transaction 1 - 1 event - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, + expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ + `%ctransaction %c1 %c- %c1 event %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, + 'color: #757575; font-weight: normal;', // transaction + 'color: default; font-weight: normal;', // 1 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 1 event + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 00:00:00 + 'color: #757575; font-weight: normal;', // - + 'color: #757575; font-weight: normal;', // 345.00 ms + 'color: #757575; font-weight: normal;', // : + 'color: #0072B2; font-weight: bold;', // retrieved value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + ], + }, + ]; + + it.each(testCases)( + 'should show correctly with options $binary (number=$showTransactionNumber, events=$showTransactionEventsCount, time=$showTransactionElapsedTime, locale=$showTransactionLocaleTime)', + ({ + showTransactionNumber, + showTransactionEventsCount, + showTransactionElapsedTime, + showTransactionLocaleTime, + expected, + }) => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + showTransactionNumber, + showTransactionEventsCount, + showTransactionElapsedTime, + showTransactionLocaleTime, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(345); // Fake the delay of the transaction + return 0; + }); + store.get(testAtom); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + [expected(testAtom)], + [`initialized value of ${testAtom} to 0`, { value: 0 }], + ]); + }, + ); + + it.each(testCases)( + 'should show correctly with colors with options $binary (number=$showTransactionNumber, events=$showTransactionEventsCount, time=$showTransactionElapsedTime, locale=$showTransactionLocaleTime)', + ({ + showTransactionNumber, + showTransactionEventsCount, + showTransactionElapsedTime, + showTransactionLocaleTime, + expectedColors, + }) => { + store = createLoggedStore(store, { + formatter: consoleFormatter({ + ...defaultFormatterOptions, + formattedOutput: true, + showTransactionNumber, + showTransactionEventsCount, + showTransactionElapsedTime, + showTransactionLocaleTime, + }), + }); + + const testAtom = atom(() => { + vi.advanceTimersByTime(345); // Fake the delay of the transaction + return 0; + }); + store.get(testAtom); + + const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; + expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); + + vi.runAllTimers(); + + expect(consoleMock.log.mock.calls).toEqual([ + expectedColors(testAtom, atomNumber!), + [ + `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, + 'color: #0072B2; font-weight: bold;', // initialized value + 'color: #757575; font-weight: normal;', // of + 'color: #757575; font-weight: normal;', // atom + 'color: default; font-weight: normal;', // atomNumber + 'color: #757575; font-weight: normal;', // to + 'color: default; font-weight: normal;', // 0 + { value: 0 }, + ], + ]); + }, + ); + }); +}); diff --git a/tests/atoms-logger.test.ts b/tests/atoms-logger.test.ts deleted file mode 100644 index d345c74..0000000 --- a/tests/atoms-logger.test.ts +++ /dev/null @@ -1,6262 +0,0 @@ -import { atom } from 'jotai'; -import type { PrimitiveAtom } from 'jotai'; -import { atomFamily } from 'jotai-family'; -import { loadable } from 'jotai-loadable'; -import { createStore } from 'jotai/vanilla'; -import { - type Mock, - type MockInstance, - afterEach, - beforeEach, - describe, - expect, - it, - onTestFinished, - vi, -} from 'vitest'; - -import { isAtomsLoggerBoundToStore } from '../src/bind-atoms-logger-to-store.js'; -import { ATOMS_LOGGER_SYMBOL } from '../src/consts/atom-logger-symbol.js'; -import { type AtomsLoggerOptions, bindAtomsLoggerToStore } from '../src/index.js'; -import type { AnyAtom, AtomId, Store, StoreWithAtomsLogger } from '../src/types/atoms-logger.js'; - -let mockDate: MockInstance; - -beforeEach(() => { - vi.useFakeTimers({ now: 0 }); - vi.stubEnv('TZ', 'UTC'); - mockDate = vi - .spyOn(Date.prototype, 'toLocaleTimeString') - .mockImplementation(function toLocaleTimeStringMock(this: Date) { - return this.toISOString().split('T')[1]!.split('.')[0]!; // 14:39:27 - }); -}); - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllEnvs(); - mockDate.mockRestore(); -}); - -describe('bindAtomsLoggerToStore', () => { - let store: ReturnType; - let consoleMock: { - log: Mock; - group: Mock; - groupEnd: Mock; - groupCollapsed: Mock; - }; - let defaultOptions: AtomsLoggerOptions; - - beforeEach(() => { - store = createStore(); - consoleMock = { - log: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), - groupCollapsed: vi.fn(), - }; - defaultOptions = { - logger: consoleMock, - groupTransactions: false, - groupEvents: false, - formattedOutput: false, - showTransactionElapsedTime: false, - showTransactionEventsCount: false, - collapseTransactions: false, - collapseEvents: false, - autoAlignTransactions: false, - }; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('store', () => { - it('jotai-devtools should not create a dev store when calling createStore', () => { - function isDevtoolsStore(store: Store): boolean { - return 'get_internal_weak_map' in store; - } - - // Just to be sure that the test file is not running with a devtools store - expect(isDevtoolsStore(createStore())).toBeFalsy(); - }); - - it('should bind the logger to the store', () => { - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - expect(bindAtomsLoggerToStore(store, defaultOptions)).toBe(true); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(consoleMock.log.mock.calls).toEqual([]); - }); - - it('should not bind the logger to the store if the store does not contain jotai internal building blocks', () => { - const fakeStore: Store = { - get() { - throw new Error('Function not implemented.'); - }, - set() { - throw new Error('Function not implemented.'); - }, - sub() { - throw new Error('Function not implemented.'); - }, - }; - expect(bindAtomsLoggerToStore(fakeStore, defaultOptions)).toBe(false); - expect(consoleMock.log.mock.calls).toEqual([ - [ - 'Fail to bind atoms logger to', - fakeStore, - ':', - new Error('Store must be created by buildStore to read its building blocks'), - ], - ]); - }); - - it('should override store methods', () => { - const originalGet = store.get; - const originalSet = store.set; - const originalSub = store.sub; - - if (!bindAtomsLoggerToStore(store, defaultOptions)) { - expect.fail('store should be bound to logger'); - } - - expect(store.get).not.toBe(originalGet); - expect(store.set).not.toBe(originalSet); - expect(store.sub).not.toBe(originalSub); - - expect(store[ATOMS_LOGGER_SYMBOL].prevStoreGet).toBe(originalGet); - expect(store[ATOMS_LOGGER_SYMBOL].prevStoreSet).toBe(originalSet); - expect(store[ATOMS_LOGGER_SYMBOL].prevStoreSub).toBe(originalSub); - }); - - it('should call original store methods', () => { - store.get = vi.fn(store.get) as Store['get']; - store.set = vi.fn(store.set) as Store['set']; - store.sub = vi.fn(store.sub); - - const originalGet = store.get; - const originalSet = store.set; - const originalSub = store.sub; - - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - - store.get(testAtom); - expect(originalGet).toHaveBeenCalledWith(testAtom); - - store.set(testAtom, 43); - expect(originalSet).toHaveBeenCalledWith(testAtom, 43); - - const listener = vi.fn(); - store.sub(testAtom, listener); - expect(originalSub).toHaveBeenCalledWith(testAtom, listener); - }); - - it('should not bind the logger to the store if it is already bound', () => { - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - expect(bindAtomsLoggerToStore(store, defaultOptions)).toBe(true); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(bindAtomsLoggerToStore(store, defaultOptions)).toBe(true); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(consoleMock.log.mock.calls).toEqual([]); - }); - - it('should change store options when binding multiple times', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - enabled: true, - domain: 'hello', - }); - - expect((store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]).toEqual( - expect.objectContaining({ ...defaultOptions, enabled: true, domain: 'hello' }), - ); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - enabled: false, - }); - - expect((store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]).toEqual( - expect.objectContaining({ ...defaultOptions, enabled: false, domain: undefined }), - ); - }); - }); - - describe('debugLabel', () => { - it('should log atoms without debug labels', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of atom${atomNumber}`], - [`initialized value of atom${atomNumber} to 42`, { value: 42 }], - ]); - }); - - it('should log atoms with debug labels', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - testAtom.debugLabel = 'Test Atom'; - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of atom${atomNumber}:Test Atom`], - [`initialized value of atom${atomNumber}:Test Atom to 42`, { value: 42 }], - ]); - }); - - it('should log atoms with a custom toString method', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - testAtom.toString = () => 'Custom Atom'; - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of Custom Atom`], - [`initialized value of Custom Atom to 42`, { value: 42 }], - ]); - }); - - it('should log atoms with a custom toString method in colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const testAtom = atom(42); - testAtom.toString = () => 'Custom Atom'; - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %cCustom Atom`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: default; font-weight: normal;', // Custom Atom - ], - [ - `%cinitialized value %cof %cCustom Atom %cto %c42`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: default; font-weight: normal;', // Custom Atom - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 42 - { value: 42 }, - ], - ]); - }); - }); - - describe('options', () => { - it('should respect custom options', () => { - const customOptions = { - enabled: false, - shouldShowPrivateAtoms: true, - stringifyLimit: 100, - logger: consoleMock, - groupTransactions: false, - groupEvents: true, - collapseEvents: true, - collapseTransactions: false, - ownerStackLimit: 5, - }; - - if (!bindAtomsLoggerToStore(store, customOptions)) { - expect.fail('store should be bound to logger'); - } - - expect(store[ATOMS_LOGGER_SYMBOL].enabled).toBe(false); - expect(store[ATOMS_LOGGER_SYMBOL].shouldShowPrivateAtoms).toBe(true); - expect(store[ATOMS_LOGGER_SYMBOL].stringifyLimit).toBe(100); - expect(store[ATOMS_LOGGER_SYMBOL].logger).toBe(consoleMock); - expect(store[ATOMS_LOGGER_SYMBOL].groupTransactions).toBe(false); - expect(store[ATOMS_LOGGER_SYMBOL].groupEvents).toBe(true); - expect(store[ATOMS_LOGGER_SYMBOL].collapseEvents).toBe(true); - expect(store[ATOMS_LOGGER_SYMBOL].collapseTransactions).toBe(false); - expect(store[ATOMS_LOGGER_SYMBOL].ownerStackLimit).toBe(5); - }); - - describe('enabled', () => { - it('should log atom interactions when enabled', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - - it('should not log atom interactions when disabled', () => { - bindAtomsLoggerToStore(store, { enabled: false, logger: consoleMock }); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log).not.toHaveBeenCalled(); - }); - - it('should not log atom interactions anymore after disabling', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, enabled: true }); - - const testAtom = atom(42); - - store.get(testAtom); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - - vi.clearAllMocks(); - - bindAtomsLoggerToStore(store, { ...defaultOptions, enabled: false }); - - store.get(testAtom); - store.set(testAtom, 43); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([]); - }); - }); - - describe('shouldShowAtom', () => { - it('should respect shouldShowAtom option', () => { - const shouldShowAtom = (a: AnyAtom) => a === testAtom1; - bindAtomsLoggerToStore(store, { ...defaultOptions, shouldShowAtom }); - - const testAtom1 = atom(1); - const testAtom2 = atom(2); - - store.get(testAtom1); - vi.runAllTimers(); - - expect(consoleMock.log).toHaveBeenCalled(); - consoleMock.log.mockClear(); - - store.get(testAtom2); - vi.runAllTimers(); - - expect(consoleMock.log).not.toHaveBeenCalled(); - }); - }); - - describe('shouldShowPrivateAtoms', () => { - it('should not log private atoms by default', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const privateAtom = atom(0); - privateAtom.debugPrivate = true; - - const publicAtom = atom(1); - publicAtom.debugLabel = 'Public Atom'; - - store.get(privateAtom); - store.get(publicAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${publicAtom}`], - [`initialized value of ${publicAtom} to 1`, { value: 1 }], - ]); - }); - - it('should log private atoms when shouldShowPrivateAtoms is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - shouldShowPrivateAtoms: true, - }); - - const privateAtom = atom(0); - privateAtom.debugPrivate = true; - privateAtom.debugLabel = 'Private Atom'; - - const publicAtom = atom(1); - publicAtom.debugLabel = 'Public Atom'; - - store.get(privateAtom); - store.get(publicAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${privateAtom}`], - [`initialized value of ${privateAtom} to 0`, { value: 0 }], - - [`transaction 2 : retrieved value of ${publicAtom}`], - [`initialized value of ${publicAtom} to 1`, { value: 1 }], - ]); - }); - }); - - describe('showTransactionNumber', () => { - it('should not log transaction numbers when showTransactionNumber is disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not log transaction when showTransactionNumber, showTransactionElapsedTime and showTransactionLocaleTime are disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber: false, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - }); - - describe('showTransactionElapsedTime', () => { - it('should log elapsed time when showTransactionElapsedTime is enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(123); // Fake the delay of the transaction - return 0; - }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 123.00 ms : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not log elapsed time when showTransactionElapsedTime is disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not log elapsed time if endTimestamp is equal or less than startTimestamp', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(0); // No delay here (with fake timers) - return 0; - }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should call performance.now when showTransactionElapsedTime is enabled', () => { - const performanceNowSpy = vi.spyOn(performance, 'now'); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - showTransactionLocaleTime: false, - synchronous: true, // To avoid requestIdleCallback calls that would also call performance.now - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Called at the start and the end of the transaction - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should call performance.now when showTransactionLocaleTime is enabled', () => { - const performanceNowSpy = vi.spyOn(performance, 'now'); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: false, - showTransactionLocaleTime: true, - synchronous: true, // To avoid requestIdleCallback calls that would also call performance.now - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Called at the start and the end of the transaction - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 00:00:00 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not call performance.now when showTransactionElapsedTime and showTransactionLocaleTime are disabled', () => { - const performanceNowSpy = vi.spyOn(performance, 'now'); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - synchronous: true, // To avoid requestIdleCallback calls that would also call performance.now - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(performanceNowSpy).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - }); - - describe('showTransactionLocaleTime', () => { - it('should log timestamps when showTransactionLocaleTime is enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionLocaleTime: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 00:00:00 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not log timestamps when showTransactionLocaleTime is disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionLocaleTime: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should log timestamps and elapsed time when both options are enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(234); // Fake the delay of the transaction - return 0; - }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 00:00:00 - 234.00 ms : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should log timestamps and elapsed time with colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - formattedOutput: true, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(456); // Fake the delay of the transaction - return 0; - }); - store.get(testAtom); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c- %c00:00:00 %c- %c456.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 456.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - }); - - describe('showTransactionEventsCount', () => { - it('should show the number of events when showTransactionEventsCount is enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 1 event : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not show the number of events when showTransactionEventsCount is disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show the correct number of events for multiple events in a transaction', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: true, - }); - - const atom1 = atom(0); - const atom2 = atom(0); - const derivedAtom = atom((get) => get(atom1) + get(atom2)); - - store.get(derivedAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 3 events : retrieved value of ${derivedAtom}`], - [`initialized value of ${atom1} to 0`, { value: 0 }], - [`initialized value of ${atom2} to 0`, { value: 0 }], - [ - `initialized value of ${derivedAtom} to 0`, - { value: 0, dependencies: [`${atom1}`, `${atom2}`] }, - ], - ]); - }); - - it('should show singular form for one event', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: true, - }); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 1 event : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - - it('should show events count with colors when formattedOutput is enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: true, - formattedOutput: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c- %c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - }); - - describe('autoAlignTransactions', () => { - it('should automatically align transaction components when autoAlignTransactions is enabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - autoAlignTransactions: true, - }); - - // transaction 1 - 1 event - 1.00 ms - const atom1 = atom(() => { - vi.advanceTimersByTime(1); - return 0; - }); - store.get(atom1); - vi.runAllTimers(); - - // transaction 2 - 2 events - 123.00 ms - const atom2 = atom(() => { - vi.advanceTimersByTime(123); - return 0; - }); - const atom3 = atom((get) => get(atom2)); - store.get(atom3); - vi.runAllTimers(); - - // transaction 3 - 12 events - 10.00 ms - const atom4 = atom(() => { - vi.advanceTimersByTime(10); - return 0; - }); - const atoms = Array.from({ length: 10 }, () => atom(0)); - const atom5 = atom((get) => atoms.reduce((sum, a) => sum + get(a), get(atom4))); - store.get(atom5); - vi.runAllTimers(); - - // transaction 4 - 1 event - 11.11 ms - const atom6 = atom(() => { - vi.advanceTimersByTime(11.11); - return 0; - }); - store.get(atom6); - vi.runAllTimers(); - - // transaction 5 - 2 events - 1.00 ms - const atom7 = atom(() => { - vi.advanceTimersByTime(1); - return 0; - }); - const atom8 = atom((get) => get(atom7)); - store.get(atom8); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 1 event - 1.00 ms : retrieved value of ${atom1}`], - // ^-- "s" padding - [`initialized value of ${atom1} to 0`, { value: 0 }], - - [`transaction 2 - 2 events - 123.00 ms : retrieved value of ${atom3}`], - [`initialized value of ${atom2} to 0`, { value: 0 }], - [`initialized value of ${atom3} to 0`, { value: 0, dependencies: [`${atom2}`] }], - - // v-- align - [`transaction 3 - 12 events - 10.00 ms : retrieved value of ${atom5}`], - [`initialized value of ${atom4} to 0`, { value: 0 }], - ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), - [ - `initialized value of ${atom5} to 0`, - { - value: 0, - dependencies: [`${atom4}`, ...atoms.map((a) => `${a}`)], - }, - ], - - // v---- align ----v - [`transaction 4 - 1 event - 11.11 ms : retrieved value of ${atom6}`], - // ^-- "s" padding - [`initialized value of ${atom6} to 0`, { value: 0 }], - - // v---- align ---v - [`transaction 5 - 2 events - 1.00 ms : retrieved value of ${atom8}`], - [`initialized value of ${atom7} to 0`, { value: 0 }], - [`initialized value of ${atom8} to 0`, { value: 0, dependencies: [`${atom7}`] }], - ]); - }); - - it('should align left events count when autoAlignTransactions is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber: false, - showTransactionEventsCount: true, - autoAlignTransactions: true, - }); - - // 1 event - const atom1 = atom(0); - store.get(atom1); - vi.runAllTimers(); - - // 2 events - const atom2 = atom(0); - const atom3 = atom((get) => get(atom2)); - store.get(atom3); - vi.runAllTimers(); - - // 11 events - const atoms = Array.from({ length: 10 }, () => atom(0)); - const atom4 = atom((get) => atoms.reduce((sum, a) => sum + get(a), 0)); - store.get(atom4); - vi.runAllTimers(); - - // 1 event - const atom5 = atom(0); - store.get(atom5); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`1 event : retrieved value of ${atom1}`], - // ^-- "s" padding - [`initialized value of ${atom1} to 0`, { value: 0 }], - - [`2 events : retrieved value of ${atom3}`], - [`initialized value of ${atom2} to 0`, { value: 0 }], - [`initialized value of ${atom3} to 0`, { value: 0, dependencies: [`${atom2}`] }], - - [`11 events : retrieved value of ${atom4}`], - ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), - [ - `initialized value of ${atom4} to 0`, - { value: 0, dependencies: atoms.map((a) => `${a}`) }, - ], - // v-- align - [`1 event : retrieved value of ${atom5}`], - // ^-- "s" padding - [`initialized value of ${atom5} to 0`, { value: 0 }], - ]); - }); - - it('should align left elapsed time when autoAlignTransactions is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber: false, - showTransactionEventsCount: false, - showTransactionLocaleTime: false, - showTransactionElapsedTime: true, - autoAlignTransactions: true, - }); - - // Short elapsed time - const atom1 = atom(() => { - vi.advanceTimersByTime(5.5); - return 0; - }); - store.get(atom1); - vi.runAllTimers(); - - // Longer elapsed time - const atom2 = atom(() => { - vi.advanceTimersByTime(123.45); - return 0; - }); - store.get(atom2); - vi.runAllTimers(); - - // Short elapsed time again - const atom3 = atom(() => { - vi.advanceTimersByTime(7.89); - return 0; - }); - store.get(atom3); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`5.50 ms : retrieved value of ${atom1}`], - [`initialized value of ${atom1} to 0`, { value: 0 }], - - [`123.45 ms : retrieved value of ${atom2}`], - [`initialized value of ${atom2} to 0`, { value: 0 }], - // v-- align - [`7.89 ms : retrieved value of ${atom3}`], - [`initialized value of ${atom3} to 0`, { value: 0 }], - ]); - }); - - it('should log zero elapsed time when autoAlignTransactions is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionElapsedTime: true, - autoAlignTransactions: true, - }); - - const atom1 = atom(() => { - vi.advanceTimersByTime(1234); - return 0; - }); - store.get(atom1); - vi.runAllTimers(); - - const atom2 = atom(0); - store.get(atom2); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 1234.00 ms : retrieved value of ${atom1}`], - [`initialized value of ${atom1} to 0`, { value: 0 }], - - // v-- still present to align with previous transaction - [`transaction 2 - 0.00 ms : retrieved value of ${atom2}`], - [`initialized value of ${atom2} to 0`, { value: 0 }], - ]); - }); - - it('should not apply alignment when autoAlignTransactions is disabled', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - autoAlignTransactions: false, - }); - - // 12 events - const atom1 = atom(() => { - vi.advanceTimersByTime(1234); - return 0; - }); - const atoms = Array.from({ length: 11 }, () => atom(0)); - const atom2 = atom((get) => atoms.reduce((sum, a) => sum + get(a), get(atom1))); - store.get(atom2); - vi.runAllTimers(); - - // 1 event - const atom3 = atom(() => { - vi.advanceTimersByTime(1); - return 0; - }); - store.get(atom3); - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 - 13 events - 1234.00 ms : retrieved value of ${atom2}`], - [`initialized value of ${atom1} to 0`, { value: 0 }], - ...atoms.map((a) => [`initialized value of ${a} to 0`, { value: 0 }]), - [ - `initialized value of ${atom2} to 0`, - { value: 0, dependencies: [`${atom1}`, ...atoms.map((a) => `${a}`)] }, - ], - - [`transaction 2 - 1 event - 1.00 ms : retrieved value of ${atom3}`], - [`initialized value of ${atom3} to 0`, { value: 0 }], - ]); - }); - }); - - describe('indentSpaces', () => { - it('should log indentation when `indentSpaces` is set to a value greater than 0', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, indentSpaces: 2 }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [` initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not log indentation when `indentSpaces` is set to 0', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, indentSpaces: 0 }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should log group indentation when `indentSpaces` is set to a value greater than 0', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - indentSpaces: 3, - groupTransactions: true, - groupEvents: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([ - // 0 spaces - [`transaction 1 : retrieved value of ${testAtom}`], - // 3 spaces - [` initialized value of ${testAtom} to 0`], - ]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - // 6 spaces - [' value', 0], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); - }); - - it('should log sub-log indentation when `indentSpaces` is set and there are sub-logs', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, indentSpaces: 2 }); - - const aAtom = atom(1); - const bAtom = atom((get) => get(aAtom) * 2); - store.get(bAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${bAtom}`], - [` initialized value of ${aAtom} to 1`, { value: 1 }], - [` initialized value of ${bAtom} to 2`, { dependencies: [`${aAtom}`], value: 2 }], - ]); - }); - }); - - describe('stringifyLimit', () => { - it('should truncate atom values with stringifyLimit', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, stringifyLimit: 5 }); - - const testAtom = atom({ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }); - store.get(testAtom); - store.set(testAtom, { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [ - `initialized value of ${testAtom} to {"a":…`, - { value: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }, - ], - [ - `transaction 2 : set value of ${testAtom} to {"a":…`, - { value: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }, - ], - [ - `changed value of ${testAtom} from {"a":… to {"a":…`, - { - oldValue: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }, - newValue: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }, - }, - ], - ]); - }); - - it('should truncate atom values by default', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions }); - - const value = Array.from({ length: 60 }, () => 'a').join(''); - const testAtom = atom(value); - store.get(testAtom); - - vi.runAllTimers(); - - const expected = '"' + Array.from({ length: 49 }, () => 'a').join('') + '…'; - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to ${expected}`, { value: value }], - ]); - }); - - it('should not truncate atom values when stringifyLimit is 0', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, stringifyLimit: 0 }); - - const value = Array.from({ length: 60 }, () => 'a').join(''); - const testAtom = atom(value); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to "${value}"`, { value: value }], - ]); - }); - }); - - describe('stringifyValues', () => { - const testAtom = atom({ foo: 'bar' } as unknown); - const setTestAtom = atom(null, (get, set, newValue: unknown) => { - set(testAtom, newValue); - return 'something'; - }); - - it('should stringify values when stringifyValues is true and formattedOutput is false', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: false, - stringifyValues: true, - }); - - store.get(testAtom); - store.set(setTestAtom, { fizz: 'buzz' }); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to {"foo":"bar"}`, { value: { foo: 'bar' } }], - [ - `transaction 2 : called set of ${setTestAtom} with {"fizz":"buzz"} and returned "something"`, - { args: [{ fizz: 'buzz' }], result: 'something' }, - ], - [ - `changed value of ${testAtom} from {"foo":"bar"} to {"fizz":"buzz"}`, - { newValue: { fizz: 'buzz' }, oldValue: { foo: 'bar' } }, - ], - ]); - }); - - it('should stringify values with colors when stringifyValues is true and formattedOutput is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - stringifyValues: true, - }); - - const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - const setTestAtomNumber = /atom(\d+)(.*)/.exec(setTestAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(setTestAtomNumber!))).toBeTruthy(); - - store.get(testAtom); - store.set(setTestAtom, { fizz: 'buzz' }); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${testAtomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${testAtomNumber} %cto %c{"foo":"bar"}`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // {"foo":"bar"} - { value: { foo: 'bar' } }, - ], - [ - `%ctransaction %c2 %c: %ccalled set %cof %catom%c${setTestAtomNumber} %cwith %c{"fizz":"buzz"} %cand returned %c"something"`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 2 - 'color: #757575; font-weight: normal;', // : - 'color: #E69F00; font-weight: bold;', // called set - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 2 - 'color: #757575; font-weight: normal;', // with - 'color: default; font-weight: normal;', // {"fizz":"buzz"} - 'color: #757575; font-weight: normal;', // and returned - 'color: default; font-weight: normal;', // "something" - { args: [{ fizz: 'buzz' }], result: 'something' }, - ], - [ - `%cchanged value %cof %catom%c${testAtomNumber} %cfrom %c{"foo":"bar"} %cto %c{"fizz":"buzz"}`, - 'color: #56B4E9; font-weight: bold;', // changed value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // from - 'color: default; font-weight: normal;', // {"foo":"bar"} - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // {"fizz":"buzz"} - { newValue: { fizz: 'buzz' }, oldValue: { foo: 'bar' } }, - ], - ]); - }); - - it('should log values as is when stringifyValues is false and formattedOutput is false', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: false, - stringifyValues: false, - }); - - store.get(testAtom); - store.set(setTestAtom, { fizz: 'buzz' }); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to`, { foo: 'bar' }], - [ - `transaction 2 : called set of ${setTestAtom} with`, - { fizz: 'buzz' }, - `and returned something`, - ], - [`changed value of ${testAtom} from`, { foo: 'bar' }, `to`, { fizz: 'buzz' }], - ]); - }); - - it('should log values using string substitution and colors when stringifyValues is false and formattedOutput is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - stringifyValues: false, - }); - - const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - const setTestAtomNumber = /atom(\d+)(.*)/.exec(setTestAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(setTestAtomNumber!))).toBeTruthy(); - - store.get(testAtom); - store.set(setTestAtom, { fizz: 'buzz' }); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${testAtomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${testAtomNumber} %cto %c%o`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // {"foo":"bar"} - { foo: 'bar' }, - ], - [ - `%ctransaction %c2 %c: %ccalled set %cof %catom%c${setTestAtomNumber} %cwith %c%o %cand returned %c%o`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 2 - 'color: #757575; font-weight: normal;', // : - 'color: #E69F00; font-weight: bold;', // called set - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 2 - 'color: #757575; font-weight: normal;', // with - 'color: default; font-weight: normal;', // {"fizz":"buzz"} - { fizz: 'buzz' }, - 'color: #757575; font-weight: normal;', // and returned - 'color: default; font-weight: normal;', // "something" - 'something', - ], - [ - `%cchanged value %cof %catom%c${testAtomNumber} %cfrom %c%o %cto %c%o`, - 'color: #56B4E9; font-weight: bold;', // changed value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // from - 'color: default; font-weight: normal;', // {"foo":"bar"} - { foo: 'bar' }, - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // {"fizz":"buzz"} - { fizz: 'buzz' }, - ], - ]); - }); - }); - - describe('stringify', () => { - it('should use stringify function when provided', () => { - const customStringify = (value: unknown) => { - if (typeof value === 'object' && value !== null) { - return JSON.stringify(value, null, 2); - } - return String(value); - }; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringify: customStringify, - }); - - const testAtom = atom({ foo: 'bar' }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to {\n "foo": "bar"\n}`, { value: { foo: 'bar' } }], - ]); - }); - - it('should catch errors of the custom stringify function', () => { - const customStringify = () => { - throw new Error('Custom stringify error'); - }; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringify: customStringify, - }); - - const testAtom = atom({ foo: 'bar' }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to [Unknown]`, { value: { foo: 'bar' } }], - ]); - }); - - it('should truncate values when using custom stringify function', () => { - const customStringify = String; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringify: customStringify, - stringifyLimit: 5, - }); - - const testAtom = atom('1234567890'); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 12345…`, { value: '1234567890' }], - ]); - }); - - it("should not crash if stringify doesn't returns a string", () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringify: () => ({ foo: 'bar' }) as unknown as string, - }); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to [Unknown]`, { value: 42 }], - ]); - }); - }); - - describe('domain', () => { - it('should not log domain when empty', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - domain: '', - }); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - - it('should log domain when set', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - domain: 'test-domain', - }); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`test-domain - transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - - it('should log domain with colors when set', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - domain: 'test-domain', - }); - - const testAtom = atom(42); - store.get(testAtom); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctest-domain %c- %ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // test-domain - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c42`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 42 - { value: 42 }, - ], - ]); - }); - }); - - describe('synchronous', () => { - it('should log asynchronously by default', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - store.get(testAtom); - store.set(testAtom, 43); - - expect(consoleMock.log.mock.calls).toEqual([]); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], - [`changed value of ${testAtom} from 42 to 43`, { oldValue: 42, newValue: 43 }], - ]); - }); - - it('should log synchronously when synchronous is true', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, synchronous: true }); - - const testAtom = atom(42); - store.get(testAtom); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - - consoleMock.log.mockClear(); - store.set(testAtom, 43); - - // vi.runAllTimers(); // No need to run timers, it should log synchronously - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], - [`changed value of ${testAtom} from 42 to 43`, { oldValue: 42, newValue: 43 }], - ]); - }); - - it('should ignore transactionDebounceMs, requestIdleCallbackTimeoutMs and maxProcessingTimeMs options when synchronous is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - synchronous: true, - requestIdleCallbackTimeoutMs: 345, - transactionDebounceMs: 456, - maxProcessingTimeMs: 789, - }); - - const options = (store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]; - - expect(options).toEqual( - expect.objectContaining({ - requestIdleCallbackTimeoutMs: -1, - transactionDebounceMs: -1, - maxProcessingTimeMs: -1, - }), - ); - expect(options).not.toEqual( - expect.objectContaining({ - synchronous: true, - }), - ); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - synchronous: false, - requestIdleCallbackTimeoutMs: 345, - transactionDebounceMs: 456, - }); - - expect(options).toEqual( - expect.objectContaining({ - requestIdleCallbackTimeoutMs: 345, - transactionDebounceMs: 456, - }), - ); - }); - }); - - describe('transactionDebounceMs', () => { - it('should log transactions with debounce by default', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom('trans-1.0'); - const setTestAtom = atom(null, (get, set) => { - setTimeout(() => { - // This is a new unknown transaction - set(testAtom, 'trans-1.1'); - vi.advanceTimersByTime(249); // debounce - set(testAtom, 'trans-1.2'); - vi.advanceTimersByTime(249); // debounce - set(testAtom, 'trans-1.3'); - - // Will be in another transaction if >= 250ms - vi.advanceTimersByTime(250); - set(testAtom, 'trans-2.1'); - }, 1000); - }); - store.set(setTestAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], - [ - `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, - { - newValue: 'trans-1.3', - oldValues: ['trans-1.1', 'trans-1.2'], - }, - ], - - [`transaction 2`], - [ - `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, - { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, - ], - ]); - }); - - it('should log transactions with debounce with transactionDebounceMs option', () => { - const transactionDebounceMs = 100; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - transactionDebounceMs, - }); - - const testAtom = atom('trans-1.0'); - const setTestAtom = atom(null, (get, set) => { - setTimeout(() => { - // This is a new unknown transaction - set(testAtom, 'trans-1.1'); - vi.advanceTimersByTime(transactionDebounceMs - 1); // debounce - set(testAtom, 'trans-1.2'); - vi.advanceTimersByTime(transactionDebounceMs - 1); // debounce - set(testAtom, 'trans-1.3'); - - // Will be in another transaction if >= transactionDebounceMs - vi.advanceTimersByTime(transactionDebounceMs); - set(testAtom, 'trans-2.1'); - }, 1000); - }); - store.set(setTestAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], - [ - `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, - { - newValue: 'trans-1.3', - oldValues: ['trans-1.1', 'trans-1.2'], - }, - ], - - [`transaction 2`], - [ - `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, - { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, - ], - ]); - }); - - it('should log transactions without debounce when transactionDebounceMs is 0', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - transactionDebounceMs: 0, - }); - - const testAtom = atom('trans-1.0'); - const setTestAtom = atom(null, (get, set) => { - setTimeout(() => { - set(testAtom, 'trans-1.1'); // This is a new unknown transaction - set(testAtom, 'trans-1.2'); // This is a new unknown transaction - vi.advanceTimersByTime(1); - set(testAtom, 'trans-2.1'); // This is a new unknown transaction - }, 1000); - }); - store.set(setTestAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], - - [`transaction 2`], - [ - `changed value of ${testAtom} from "trans-1.1" to "trans-1.2"`, - { newValue: 'trans-1.2', oldValue: 'trans-1.1' }, - ], - - [`transaction 3`], - [ - `changed value of ${testAtom} from "trans-1.2" to "trans-2.1"`, - { newValue: 'trans-2.1', oldValue: 'trans-1.2' }, - ], - ]); - }); - }); - - describe('requestIdleCallbackTimeoutMs', () => { - const transactionCallbacks: (() => void)[] = []; - let requestIdleCallbackMockFn: Mock; - - beforeEach(() => { - requestIdleCallbackMockFn = vi.fn((cb: IdleRequestCallback) => { - transactionCallbacks.push(() => { - cb({ didTimeout: false, timeRemaining: () => 50 }); - }); - return 1; - }); - globalThis.requestIdleCallback = requestIdleCallbackMockFn; - }); - - afterEach(() => { - delete (globalThis as Partial).requestIdleCallback; - }); - - it('should schedule and log transactions using requestIdleCallback by default', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - store.get(testAtom); - vi.runAllTimers(); - - expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { - timeout: 250, - }); - - expect(consoleMock.log.mock.calls).toEqual([]); - requestIdleCallbackMockFn.mockClear(); - transactionCallbacks.shift()!(); // Run the first transaction - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - }); - - it('should schedule and log transactions using requestIdleCallback with requestIdleCallbackTimeoutMs option', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, requestIdleCallbackTimeoutMs: 666 }); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - store.get(testAtom); - vi.runAllTimers(); - - expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { - timeout: 666, - }); - - expect(consoleMock.log.mock.calls).toEqual([]); - requestIdleCallbackMockFn.mockClear(); - transactionCallbacks.shift()!(); // Run the first transaction - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - }); - - it('should schedule and log transactions using requestIdleCallback without timeout with requestIdleCallbackTimeoutMs option to 0', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, requestIdleCallbackTimeoutMs: 0 }); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - store.get(testAtom); - vi.runAllTimers(); - - expect(requestIdleCallbackMockFn).toHaveBeenCalledExactlyOnceWith(expect.any(Function), { - timeout: 0, - }); - - expect(consoleMock.log.mock.calls).toEqual([]); - requestIdleCallbackMockFn.mockClear(); - transactionCallbacks.shift()!(); // Run the first transaction - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - }); - - it('should log transactions synchronously when requestIdleCallbackTimeoutMs is -1', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - requestIdleCallbackTimeoutMs: -1, - }); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - store.get(testAtom); - - expect(consoleMock.log.mock.calls).toEqual([]); - - vi.advanceTimersByTime(250); // Default debounce time - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should log transactions synchronously when requestIdleCallbackTimeoutMs and transactionDebounceMs are -1', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - requestIdleCallbackTimeoutMs: -1, - transactionDebounceMs: -1, - }); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - store.get(testAtom); - - // vi.runAllTimers(); // No need to run timers, it should log synchronously - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - }); - - describe('maxProcessingTimeMs', () => { - it('should process and log transactions in chunks when processing takes too long by default', () => { - const performanceNowSpy = vi.spyOn(performance, 'now').mockReturnValue(0); - - let callCount = 0; - performanceNowSpy.mockImplementation(() => { - callCount++; - return callCount === 1 ? 0 : 100; // First call: 0ms (start), second call: 100ms (exceeded) - }); - - const requestIdleCallbacks: (() => void)[] = []; // Store scheduled callbacks - const requestIdleCallbackMockFn = vi.fn().mockImplementation((cb: IdleRequestCallback) => { - requestIdleCallbacks.push(() => { - cb({ didTimeout: false, timeRemaining: () => 50 }); - }); - return 1; - }); - globalThis.requestIdleCallback = requestIdleCallbackMockFn; - onTestFinished(() => { - delete (globalThis as Partial).requestIdleCallback; - }); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - - // Don't call `performance.now()` with timings options to avoid interfering with the test - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - }); - - // Create 12 atoms : 10 will be logged in the first chunk, 2 in the second chunk - const testAtoms = Array.from({ length: 12 }, (_, i) => atom(i + 1)); - for (const testAtom of testAtoms) { - store.get(testAtom); - } - - vi.runAllTimers(); - - // Waiting for requestIdleCallback - expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); - expect(performanceNowSpy).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - requestIdleCallbackMockFn.mockClear(); - performanceNowSpy.mockClear(); - consoleMock.log.mockClear(); - - requestIdleCallbacks.shift()!(); // Invoke the 1st scheduled callback - - expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); // Called again due to time limit - expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Start + first check - expect(consoleMock.log.mock.calls).toEqual( - Array.from({ length: 10 }, (_, i) => [ - [`transaction ${i + 1} : retrieved value of ${testAtoms[i]}`], - [`initialized value of ${testAtoms[i]} to ${i + 1}`, { value: i + 1 }], - ]).flat(1), - ); - requestIdleCallbackMockFn.mockClear(); - performanceNowSpy.mockClear(); - consoleMock.log.mockClear(); - - requestIdleCallbacks.shift()!(); // Invoke the 2nd scheduled callback - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); // Finished processing - expect(performanceNowSpy).toHaveBeenCalledTimes(1); // Start only (not reached checkTimeInterval) - expect(consoleMock.log.mock.calls).toEqual( - Array.from({ length: 2 }, (_, i) => [ - [`transaction ${i + 11} : retrieved value of ${testAtoms[i + 10]}`], - [`initialized value of ${testAtoms[i + 10]} to ${i + 11}`, { value: i + 11 }], - ]).flat(1), - ); - }); - }); - }); - - describe('promises', () => { - it('should log promise states', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const promiseAtom = atom(() => { - return new Promise((resolve) => - setTimeout(() => { - resolve(42); - }, 0), - ); - }); - - const otherPromiseAtom = atom(() => { - return new Promise((resolve, reject) => - setTimeout(() => { - reject(new Error('Promise rejected')); - }, 0), - ); - }); - - void store.get(promiseAtom); - - await vi.advanceTimersByTimeAsync(1000); - - void store.get(otherPromiseAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [`resolved initial promise of ${promiseAtom} to 42`, { value: 42 }], - - [`transaction 2 : retrieved value of ${otherPromiseAtom}`], - [`pending initial promise of ${otherPromiseAtom}`], - [ - `rejected initial promise of ${otherPromiseAtom} to Error: Promise rejected`, - { error: new Error('Promise rejected') }, - ], - ]); - }); - - it('should log rejected promises', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const myError = new Error('Promise rejected'); - const promiseAtom = atom(() => { - return new Promise((_, reject) => - setTimeout(() => { - reject(myError); - }, 1000), - ); - }); - - const promise = store.get(promiseAtom); - - await vi.advanceTimersByTimeAsync(2000); - - await expect(promise).rejects.toThrow('Promise rejected'); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - - [`transaction 2 : rejected promise of ${promiseAtom}`], - [ - `rejected initial promise of ${promiseAtom} to Error: Promise rejected`, - { error: myError }, - ], - ]); - }); - - it('should show promise resolved and rejected in the same transaction if they resolve before the debounce', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const instantPromiseAtom = atom(() => { - return new Promise((resolve) => - setTimeout(() => { - resolve(42); - }, 0), - ); - }); - - const instantPromiseRejectedAtom = atom(() => { - return new Promise((_, reject) => - setTimeout(() => { - reject(new Error('Promise rejected')); - }, 0), - ); - }); - - const slowerPromiseAtom = atom(() => { - return new Promise((resolve) => - setTimeout(() => { - resolve(42); - }, 1000), - ); - }); - - const slowerPromiseRejectedAtom = atom(() => { - return new Promise((resolve, reject) => - setTimeout(() => { - reject(new Error('Promise rejected')); - }, 1000), - ); - }); - - void store.get(instantPromiseAtom); - await vi.advanceTimersByTimeAsync(200); - - void store.get(instantPromiseRejectedAtom); - await vi.advanceTimersByTimeAsync(200); - - void store.get(slowerPromiseAtom); - void store.get(slowerPromiseRejectedAtom); - - await vi.advanceTimersByTimeAsync(2000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${instantPromiseAtom}`], - [`pending initial promise of ${instantPromiseAtom}`], - [`resolved initial promise of ${instantPromiseAtom} to 42`, { value: 42 }], // In first transaction - - [`transaction 2 : retrieved value of ${instantPromiseRejectedAtom}`], - [`pending initial promise of ${instantPromiseRejectedAtom}`], - [ - `rejected initial promise of ${instantPromiseRejectedAtom} to Error: Promise rejected`, // In second transaction - { error: new Error('Promise rejected') }, - ], - - [`transaction 3 : retrieved value of ${slowerPromiseAtom}`], - [`pending initial promise of ${slowerPromiseAtom}`], - - [`transaction 4 : retrieved value of ${slowerPromiseRejectedAtom}`], - [`pending initial promise of ${slowerPromiseRejectedAtom}`], - - [`transaction 5 : resolved promise of ${slowerPromiseAtom}`], // In another transaction - [`resolved initial promise of ${slowerPromiseAtom} to 42`, { value: 42 }], - [ - `rejected initial promise of ${slowerPromiseRejectedAtom} to Error: Promise rejected`, - { error: new Error('Promise rejected') }, - ], - ]); - }); - - it('should show promise resolved in the same transaction if they are waiting for the same async dependency', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const otherAtom = atom(0); - const doGetOtherAtom = () => { - store.set(otherAtom, (prev) => prev + 1); // Should not be merged with the previous transaction - }; - - const dep = atom>(async () => { - return new Promise((resolve) => - setTimeout(() => { - resolve(42); - - doGetOtherAtom(); // Should not be merged BEFORE the promise transaction - setTimeout(() => { - doGetOtherAtom(); // Should not be merged AFTER the promise transaction - }, 0); - }, 1000), - ); - }); - - const prom = atomFamily((id: string) => - atom((get) => { - const dependency = get(dep); - if (dependency instanceof Promise) { - return dependency; - } - return `${id}:${dependency}`; - }), - ); - - void store.get(prom('1')); - void store.get(prom('2')); - void store.get(prom('3')); - - await vi.advanceTimersByTimeAsync(2000); - - expect(consoleMock.log.mock.calls).toEqual([ - // All pending - [`transaction 1 : retrieved value of ${prom('1')}`], - [`pending initial promise of ${dep}`], - [`pending initial promise of ${prom('1')}`, { dependencies: [`${dep}`] }], - [`transaction 2 : retrieved value of ${prom('2')}`], - [`pending initial promise of ${prom('2')}`, { dependencies: [`${dep}`] }], - [`transaction 3 : retrieved value of ${prom('3')}`], - [`pending initial promise of ${prom('3')}`, { dependencies: [`${dep}`] }], - - // Other atom in another transaction - [`transaction 4 : set value of ${otherAtom}`], - [`initialized value of ${otherAtom} to 0`, { value: 0 }], - [`changed value of ${otherAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], - - // All resolved - [`transaction 5 : resolved promise of ${dep}`], - [ - `resolved initial promise of ${dep} to 42`, - { pendingPromises: [`${prom('1')}`, `${prom('2')}`, `${prom('3')}`], value: 42 }, - ], - [`resolved initial promise of ${prom('1')} to 42`, { dependencies: [`${dep}`], value: 42 }], - [`resolved initial promise of ${prom('2')} to 42`, { dependencies: [`${dep}`], value: 42 }], - [`resolved initial promise of ${prom('3')} to 42`, { dependencies: [`${dep}`], value: 42 }], - - // Other atom in another transaction - [`transaction 6 : set value of ${otherAtom}`], - [`changed value of ${otherAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], - ]); - }); - - it('should log aborted promises', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const dependencyAtom = atom('first'); - const promiseAtom = atom(async (get, { signal }) => { - const dependency = get(dependencyAtom); - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - resolve(dependency); - }, 1000); - signal.addEventListener('abort', () => { - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - const beforePromise = store.get(promiseAtom); - - await vi.advanceTimersByTimeAsync(250); - - store.set(dependencyAtom, 'second'); // Change the dependency before the promise resolves - - const afterPromise = store.get(promiseAtom); - - await vi.advanceTimersByTimeAsync(1500); - - await expect(beforePromise).rejects.toEqual(new Error('Promise aborted')); - await expect(afterPromise).resolves.toBe('second'); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${promiseAtom}`], - [`initialized value of ${dependencyAtom} to "first"`, { value: 'first' }], - [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - - [`transaction 2 : set value of ${dependencyAtom} to "second"`, { value: 'second' }], - [ - `changed value of ${dependencyAtom} from "first" to "second"`, - { - newValue: 'second', - oldValue: 'first', - pendingPromises: [`${promiseAtom}`], - }, - ], - [`aborted initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - // This is still logged as the "initial" promise since it was aborted - [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - - [`transaction 3 : resolved promise of ${promiseAtom}`], - [ - `resolved initial promise of ${promiseAtom} to "second"`, - { dependencies: [`${dependencyAtom}`], value: 'second' }, - ], - ]); - }); - - it('should log atom promise changes', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(0); - - store.sub(testAtom, vi.fn()); - - // initial promise resolved - const promise1 = Promise.resolve(1); - store.set(testAtom, promise1); - await vi.advanceTimersByTimeAsync(0); - - // changed promise resolved - const promise2 = Promise.resolve(2); - store.set(testAtom, promise2); - await vi.advanceTimersByTimeAsync(0); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - [`mounted ${testAtom}`, { value: 0 }], - - [`transaction 2 : set value of ${testAtom} to [object Promise]`, { value: promise1 }], - [`pending promise of ${testAtom} from 0`, { oldValue: 0 }], - [`resolved promise of ${testAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], - - [`transaction 3 : set value of ${testAtom} to [object Promise]`, { value: promise2 }], - [`pending promise of ${testAtom} from 1`, { oldValue: 1 }], - [`resolved promise of ${testAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], - ]); - }); - - it('should show initial promise aborted before a new promise is pending', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const dependencyAtom = atom(0); - dependencyAtom.debugPrivate = true; - - const promiseAtom = atom(async (get, { signal }) => { - const dependency = get(dependencyAtom); - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - resolve(dependency); - }, 1000); - signal.addEventListener('abort', () => { - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - store.sub(promiseAtom, vi.fn()); - - // Initial promise aborted - await vi.advanceTimersByTimeAsync(250); - store.set(dependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves - await vi.advanceTimersByTimeAsync(1500); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [`mounted ${promiseAtom}`], - [`transaction 2`], - [`aborted initial promise of ${promiseAtom}`], // Must be before pending - [`pending initial promise of ${promiseAtom}`], - [`transaction 3 : resolved promise of ${promiseAtom}`], - [`resolved initial promise of ${promiseAtom} to 1`, { value: 1 }], - ]); - }); - - it('should not log promise resolved when promise was already aborted', async () => { - // Covers the isAborted=true branch in the .then() callback - bindAtomsLoggerToStore(store, defaultOptions); - - let externalResolve: (value: number) => void; - const promiseAtom = atom(async (get, { signal }) => { - return new Promise((resolve, reject) => { - externalResolve = resolve; - signal.addEventListener('abort', () => { - reject(new Error('aborted')); - }); - }); - }); - - const dependencyAtom = atom(0); - dependencyAtom.debugPrivate = true; - - const derivedAtom = atom(async (get) => { - const dep = get(dependencyAtom); - if (dep === 0) { - return get(promiseAtom); - } - return -1; - }); - - store.sub(derivedAtom, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); - - vi.clearAllMocks(); - - // Abort by changing the dependency, then resolve the original promise - store.set(dependencyAtom, 1); - await vi.advanceTimersByTimeAsync(250); - - // Now resolve the already-aborted promise — should NOT be logged - externalResolve!(42); - await vi.advanceTimersByTimeAsync(250); - - vi.runAllTimers(); - - // The aborted promise's resolve callback fires but isAborted=true so nothing extra is logged - expect(consoleMock.log.mock.calls).not.toContain( - expect.arrayContaining([expect.stringContaining('resolved initial promise')]), - ); - }); - - it('should show changed promise aborted before a new promise is pending', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const dependencyAtom = atom(0); - dependencyAtom.debugPrivate = true; - - const promiseAtom = atom(async (get, { signal }) => { - const dependency = get(dependencyAtom); - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - resolve(dependency); - }, 1000); - signal.addEventListener('abort', () => { - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - store.sub(promiseAtom, vi.fn()); - - // Initial promise resolved - await vi.advanceTimersByTimeAsync(1250); - - // Changed promise aborted - store.set(dependencyAtom, (prev) => prev + 1); - await vi.advanceTimersByTimeAsync(250); - store.set(dependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves - await vi.advanceTimersByTimeAsync(1500); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [`mounted ${promiseAtom}`], - - [`transaction 2 : resolved promise of ${promiseAtom}`], - [`resolved initial promise of ${promiseAtom} to 0`, { value: 0 }], - - [`transaction 3`], - [`pending promise of ${promiseAtom} from 0`, { oldValue: 0 }], - - [`transaction 4`], - [`aborted promise of ${promiseAtom} from 0`, { oldValue: 0 }], // Must be before pending - [`pending promise of ${promiseAtom} from 0`, { oldValue: 0 }], - - [`transaction 5 : resolved promise of ${promiseAtom}`], - [`resolved promise of ${promiseAtom} from 0 to 2`, { oldValue: 0, newValue: 2 }], - ]); - }); - - it('should not swap events when abort is the only event in the transaction', async () => { - // Covers add-event-to-transaction.ts:152 false branch: events.length <= 1 - bindAtomsLoggerToStore(store, defaultOptions); - - const promiseAtom = atom(0); - - store.sub(promiseAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(0); - - vi.clearAllMocks(); - - // Set a promise that will be aborted — then immediately abort it by setting another value - const neverResolve = new Promise(() => {}); - store.set(promiseAtom, neverResolve); - - // Set a new value immediately so the pending promise has no time to accumulate other events, - // and the abort fires alone in its transaction - store.set(promiseAtom, 99); - - vi.runAllTimers(); - - // Main thing is no crash — the logger handles abort-as-only-event gracefully - expect(consoleMock.log.mock.calls.length).toBeGreaterThanOrEqual(0); - }); - - it('should not swap events when the event before abort is not a pending promise event', async () => { - // Covers add-event-to-transaction.ts:154 false branch: preceding event is not pending - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const depAtom = atom(0); - depAtom.debugPrivate = true; - - // A derived atom that reads both aAtom and bAtom and depends on depAtom - const promiseAtom = atom(async (get, { signal }) => { - get(depAtom); - const a = get(aAtom); - const b = get(bAtom); - return new Promise((resolve, reject) => { - const t = setTimeout(() => { - resolve(a + b); - }, 1000); - signal.addEventListener('abort', () => { - clearTimeout(t); - reject(new Error('aborted')); - }); - }); - }); - - store.sub(promiseAtom, vi.fn()); - // Let the initial promise start - await vi.advanceTimersByTimeAsync(100); - - vi.clearAllMocks(); - - // Change aAtom (adds a changed value event for aAtom into the transaction) - // then immediately change depAtom to abort the promise - // This should result in a transaction where the event before the abort - // is a changed-value event (not a pending), so no swap should happen - store.set(aAtom, 2); - store.set(depAtom, 1); - await vi.advanceTimersByTimeAsync(1500); - - vi.runAllTimers(); - - // No crash; events logged in some order - expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); - }); - - it('should log promises in colors', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const refreshPromisesAtom = atom(0); - refreshPromisesAtom.debugPrivate = true; - - const promiseResolvedAtom = atom(async (get) => { - get(refreshPromisesAtom); - return Promise.resolve(42); - }); - const promiseRejectedAtom = atom(async (get) => { - get(refreshPromisesAtom); - return Promise.reject(new Error('Promise rejected')); - }); - - const promiseAbortedDependencyAtom = atom(0); - promiseAbortedDependencyAtom.debugPrivate = true; - const promiseAbortedAtom = atom(async (get, { signal }) => { - get(refreshPromisesAtom); - const dependency = get(promiseAbortedDependencyAtom); - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - if (dependency <= 1) { - resolve(dependency); - } else { - reject(new Error('Rejected because of dependency higher than 1')); - } - }, 1000); - signal.addEventListener('abort', () => { - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - const resolvedPromiseNumber = /atom(\d+)(.*)/.exec(promiseResolvedAtom.toString())?.[1]; - const rejectedPromiseNumber = /atom(\d+)(.*)/.exec(promiseRejectedAtom.toString())?.[1]; - const abortedPromiseNumber = /atom(\d+)(.*)/.exec(promiseAbortedAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(resolvedPromiseNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(rejectedPromiseNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(abortedPromiseNumber!))).toBeTruthy(); - - // Initial promise resolved - store.sub(promiseResolvedAtom, vi.fn()); - - // Initial promise rejected - store.sub(promiseRejectedAtom, vi.fn()); - - // Initial promise aborted - store.sub(promiseAbortedAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - store.set(promiseAbortedDependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves - - await vi.advanceTimersByTimeAsync(1500); - - // promise resolved - // promise rejected - // promise aborted - store.set(refreshPromisesAtom, (prev) => prev + 1); - await vi.advanceTimersByTimeAsync(250); - store.set(promiseAbortedDependencyAtom, (prev) => prev + 1); // Change the dependency before the promise resolves - - await vi.advanceTimersByTimeAsync(1500); - - expect(consoleMock.log.mock.calls).toEqual([ - // pending initial promise (1) - [ - `%ctransaction %c1 %c: %csubscribed %cto %catom%c${resolvedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // subscribed - `color: #757575; font-weight: normal;`, // to - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - [ - `%cpending initial promise %cof %catom%c${resolvedPromiseNumber}`, - `color: #CC79A7; font-weight: bold;`, // pending initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - [ - `%cmounted %catom%c${resolvedPromiseNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - - // pending initial promise (2) - [ - `%ctransaction %c2 %c: %csubscribed %cto %catom%c${rejectedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 2 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // subscribed - `color: #757575; font-weight: normal;`, // to - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - ], - [ - `%cpending initial promise %cof %catom%c${rejectedPromiseNumber}`, - `color: #CC79A7; font-weight: bold;`, // pending initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - ], - [ - `%cmounted %catom%c${rejectedPromiseNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - ], - - // pending initial promise (3) - [ - `%ctransaction %c3 %c: %csubscribed %cto %catom%c${abortedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // subscribed - `color: #757575; font-weight: normal;`, // to - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - [ - `%cpending initial promise %cof %catom%c${abortedPromiseNumber}`, - `color: #CC79A7; font-weight: bold;`, // pending initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - [ - `%cmounted %catom%c${abortedPromiseNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - - // resolved initial promise (1) - [ - `%ctransaction %c4 %c: %cresolved %cpromise %cof %catom%c${resolvedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 4 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // resolved - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - [ - `%cresolved %cinitial promise %cof %catom%c${resolvedPromiseNumber} %cto %c42`, - `color: #009E73; font-weight: bold;`, // resolved - `color: #CC79A7; font-weight: bold;`, // initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // 42 - { value: 42 }, - ], - // rejected initial promise (2) - [ - `%crejected %cinitial promise %cof %catom%c${rejectedPromiseNumber} %cto %cError: Promise rejected`, - `color: #D55E00; font-weight: bold;`, // rejected - `color: #CC79A7; font-weight: bold;`, // initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 6 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // Error: Promise rejected - { error: new Error(`Promise rejected`) }, - ], - - // aborted initial promise (3) - [ - `%ctransaction %c5`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 5 - ], - [ - `%caborted %cinitial promise %cof %catom%c${abortedPromiseNumber}`, - `color: #D55E00; font-weight: bold;`, // aborted - `color: #CC79A7; font-weight: bold;`, // initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - [ - `%cpending initial promise %cof %catom%c${abortedPromiseNumber}`, - `color: #CC79A7; font-weight: bold;`, // pending initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - - // resolved initial promise (3) - [ - `%ctransaction %c6 %c: %cresolved %cpromise %cof %catom%c${abortedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 6 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // resolved - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - [ - `%cresolved %cinitial promise %cof %catom%c${abortedPromiseNumber} %cto %c1`, - `color: #009E73; font-weight: bold;`, // resolved - `color: #CC79A7; font-weight: bold;`, // initial promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // 1 - { value: 1 }, - ], - - // pending promise 1 + pending promise 2 + resolved promise 1 + rejected promise 2 - [ - `%ctransaction %c7`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 7 - ], - [ - `%cpending promise %cof %catom%c${resolvedPromiseNumber} %cfrom %c42`, - `color: #CC79A7; font-weight: bold;`, // pending promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 42 - { oldValue: 42 }, - ], - [ - `%cpending promise %cof %catom%c${rejectedPromiseNumber} %cfrom %cError: Promise rejected`, - `color: #CC79A7; font-weight: bold;`, // pending promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // Error: Promise rejected - { oldError: new Error(`Promise rejected`) }, - ], - [ - `%cpending promise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, - `color: #CC79A7; font-weight: bold;`, // pending promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 1 - { oldValue: 1 }, - ], - [ - `%cresolved %cpromise %cof %catom%c${resolvedPromiseNumber} %cfrom %c42 %cto %c42`, - `color: #009E73; font-weight: bold;`, // resolved - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 42 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // 42 - { newValue: 42, oldValue: 42 }, - ], - [ - `%crejected %cpromise %cof %catom%c${rejectedPromiseNumber} %cfrom %cError: Promise rejected %cto %cError: Promise rejected`, - `color: #D55E00; font-weight: bold;`, // rejected - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // Error: Promise rejected - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // Error: Promise rejected - { newError: new Error(`Promise rejected`), oldError: new Error(`Promise rejected`) }, - ], - - // pending promise 3 + aborted promise 3 - [ - `%ctransaction %c8`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 8 - ], - [ - `%caborted %cpromise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, - `color: #D55E00; font-weight: bold;`, // aborted - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 1 - { oldValue: 1 }, - ], - [ - `%cpending promise %cof %catom%c${abortedPromiseNumber} %cfrom %c1`, - `color: #CC79A7; font-weight: bold;`, // pending promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 1 - { oldValue: 1 }, - ], - - // rejected promise 3 - [ - `%ctransaction %c9 %c: %crejected %cpromise %cof %catom%c${abortedPromiseNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 9 - `color: #757575; font-weight: normal;`, // : - `color: #D55E00; font-weight: bold;`, // rejected - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - ], - [ - `%crejected %cpromise %cof %catom%c${abortedPromiseNumber} %cfrom %c1 %cto %cError: Rejected because of dependency higher than …`, - `color: #D55E00; font-weight: bold;`, // rejected - `color: #CC79A7; font-weight: bold;`, // promise - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 3 - `color: #757575; font-weight: normal;`, // from - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // Error: Rejected because of dependency higher than 1 - { error: new Error(`Rejected because of dependency higher than 1`), oldValue: 1 }, - ], - ]); - }); - - it('should log aborted promise due to changing dependencies', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const abortedFn = vi.fn(); - - const dependencyAtom = atom(0); - - const promiseAtom = atom(async (get, { signal }) => { - get(dependencyAtom); - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - if (!signal.aborted) resolve(42); - }, 1000); - signal.addEventListener('abort', () => { - abortedFn(); - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - store.sub(promiseAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - - expect(abortedFn).not.toHaveBeenCalled(); - store.set(dependencyAtom, store.get(dependencyAtom) + 1); - expect(abortedFn).toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(2000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${promiseAtom}`], - [`initialized value of ${dependencyAtom} to 0`, { value: 0 }], - [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - [`mounted ${dependencyAtom}`, { pendingPromises: [`${promiseAtom}`], value: 0 }], - [`mounted ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - - [`transaction 2 : set value of ${dependencyAtom} to 1`, { value: 1 }], - [ - `changed value of ${dependencyAtom} from 0 to 1`, - { - dependents: [`${promiseAtom}`], - newValue: 1, - oldValue: 0, - pendingPromises: [`${promiseAtom}`], - }, - ], - [`aborted initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - [`pending initial promise of ${promiseAtom}`, { dependencies: [`${dependencyAtom}`] }], - - [`transaction 3 : resolved promise of ${promiseAtom}`], - [ - `resolved initial promise of ${promiseAtom} to 42`, - { dependencies: [`${dependencyAtom}`], value: 42 }, - ], - ]); - }); - - it('should not log aborted promise due to unmount', async () => { - // **not** aborted is expected due to https://github.com/pmndrs/jotai/issues/2625 - - bindAtomsLoggerToStore(store, defaultOptions); - - const abortedFn = vi.fn(); - - const promiseAtom = atom(async (get, { signal }) => { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - if (!signal.aborted) resolve(42); - }, 1000); - signal.addEventListener('abort', () => { - abortedFn(); - clearTimeout(timeoutId); - reject(new Error('Promise aborted')); - }); - }); - }); - - const unsubscribe = store.sub(promiseAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - - expect(abortedFn).not.toHaveBeenCalled(); - unsubscribe(); - expect(abortedFn).not.toHaveBeenCalled(); // not aborted is expected - - await vi.advanceTimersByTimeAsync(2000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [`mounted ${promiseAtom}`], - - [`transaction 2 : unsubscribed from ${promiseAtom}`], - [`unmounted ${promiseAtom}`], - - [`transaction 3 : resolved promise of ${promiseAtom}`], - [`resolved initial promise of ${promiseAtom} to 42`, { value: 42 }], - ]); - }); - }); - - describe('errors', () => { - it('should log error values without stringifying when stringifyValues is false', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringifyValues: false, - }); - - const errorAtom = atom(() => Promise.reject(new Error('initial error'))); - - store.sub(errorAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - - vi.runAllTimers(); - - // Covers event-log-pipeline.ts line 265: stringifyValues=false, isNewValueError=true, no old value - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${errorAtom}`], - [`pending initial promise of ${errorAtom}`], - [`mounted ${errorAtom}`], - [`rejected initial promise of ${errorAtom} to`, new Error('initial error')], - ]); - }); - - it('should log old error and new error without stringifying when stringifyValues is false', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - stringifyValues: false, - }); - - const depAtom = atom(0); - depAtom.debugPrivate = true; - let count = 0; - // eslint-disable-next-line @typescript-eslint/require-await - const errorAtom = atom(async (get) => { - get(depAtom); - count += 1; - throw new Error(`error ${count}`); - }); - - store.sub(errorAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - - vi.clearAllMocks(); - - // Change dep so errorAtom re-runs: old error → new pending → new error - store.set(depAtom, 1); - await vi.advanceTimersByTimeAsync(250); - - vi.runAllTimers(); - - // Covers event-log-pipeline.ts lines 215, 262: - // - line 215: stringifyValues=false, old error shown in pending log (old value was error) - // - line 262: stringifyValues=false, hasOldValue && isOldValueError, new value is also error - const calls = consoleMock.log.mock.calls; - expect(calls).toContainEqual(expect.arrayContaining([expect.stringContaining('pending')])); - expect(calls).toContainEqual(expect.arrayContaining([expect.stringContaining('rejected')])); - }); - - it('should log custom errors', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const customError = RangeError('Custom error message'); - const promiseAtom = atom(() => { - return new Promise((_, reject) => { - setTimeout(() => { - reject(customError); - }, 0); - }); - }); - - const promise = store.get(promiseAtom); - - await vi.advanceTimersByTimeAsync(1000); - - await expect(promise).rejects.toThrow('Custom error message'); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [ - `rejected initial promise of ${promiseAtom} to RangeError: Custom error message`, - { error: customError }, - ], - ]); - }); - }); - - describe('changes', () => { - it('should log atom value changes', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - - // value - store.get(testAtom); - - // old value -> new value - store.set(testAtom, 43); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - - [`transaction 2 : set value of ${testAtom} to 43`, { value: 43 }], - [`changed value of ${testAtom} from 42 to 43`, { newValue: 43, oldValue: 42 }], - ]); - }); - - it('should log atom value and promise changes', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const valueTypeAtom = atom<'value' | 'resolve' | 'reject' | 'reject2'>('resolve'); - valueTypeAtom.debugPrivate = true; - - let count = 0; - const promiseAtom = atom>(async (get) => { - count += 1; - const type = get(valueTypeAtom); - if (type === 'value') { - return count; - } else if (type === 'reject' || type === 'reject2') { - return new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`${count}`)); - }, 100); - }); - } else { - return new Promise((resolve) => { - setTimeout(() => { - resolve(count); - }, 100); - }); - } - }); - - // value - store.set(valueTypeAtom, 'value'); - store.sub(promiseAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(250); - - // value -> promise resolve - store.set(valueTypeAtom, 'resolve'); - await vi.advanceTimersByTimeAsync(250); - - // promise resolve -> promise reject - store.set(valueTypeAtom, 'reject'); - await vi.advanceTimersByTimeAsync(250); - - // promise reject -> promise resolve - store.set(valueTypeAtom, 'resolve'); - await vi.advanceTimersByTimeAsync(250); - - // promise resolve -> value - store.set(valueTypeAtom, 'value'); - await vi.advanceTimersByTimeAsync(250); - - // value -> promise reject - store.set(valueTypeAtom, 'reject'); - await vi.advanceTimersByTimeAsync(250); - - // promise reject -> promise reject - store.set(valueTypeAtom, 'reject2'); - await vi.advanceTimersByTimeAsync(250); - - // promise reject -> value - store.set(valueTypeAtom, 'value'); - await vi.advanceTimersByTimeAsync(250); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - // value - [`transaction 1 : subscribed to ${promiseAtom}`], - [`pending initial promise of ${promiseAtom}`], - [`mounted ${promiseAtom}`], - [`resolved initial promise of ${promiseAtom} to 1`, { value: 1 }], - - // value -> promise resolve - ['transaction 2'], - [`pending promise of ${promiseAtom} from 1`, { oldValue: 1 }], - [`resolved promise of ${promiseAtom} from 1 to 2`, { oldValue: 1, newValue: 2 }], - - // promise resolve -> promise reject - ['transaction 3'], - [`pending promise of ${promiseAtom} from 2`, { oldValue: 2 }], - [ - `rejected promise of ${promiseAtom} from 2 to Error: 3`, - { oldValue: 2, error: new Error('3') }, - ], - - // promise reject -> promise resolve - ['transaction 4'], - [`pending promise of ${promiseAtom} from Error: 3`, { oldError: new Error('3') }], - [ - `resolved promise of ${promiseAtom} from Error: 3 to 4`, - { oldError: new Error('3'), value: 4 }, - ], - - // promise resolve -> value - ['transaction 5'], - [`pending promise of ${promiseAtom} from 4`, { oldValue: 4 }], - [`resolved promise of ${promiseAtom} from 4 to 5`, { newValue: 5, oldValue: 4 }], - - // value -> promise reject - ['transaction 6'], - [`pending promise of ${promiseAtom} from 5`, { oldValue: 5 }], - [ - `rejected promise of ${promiseAtom} from 5 to Error: 6`, - { oldValue: 5, error: new Error('6') }, - ], - - // promise reject -> promise reject - ['transaction 7'], - [`pending promise of ${promiseAtom} from Error: 6`, { oldError: new Error('6') }], - [ - `rejected promise of ${promiseAtom} from Error: 6 to Error: 7`, - { oldError: new Error('6'), newError: new Error('7') }, - ], - - // promise reject -> value - ['transaction 8'], - [`pending promise of ${promiseAtom} from Error: 7`, { oldError: new Error('7') }], - [ - `resolved promise of ${promiseAtom} from Error: 7 to 8`, - { oldError: new Error('7'), value: 8 }, - ], - ]); - }); - - it('should merge atom value changes if they are in the same transaction', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const valueAtom = atom(0); - - const valueSetAtom = atom(null, (get, set) => { - set(valueAtom, 1); - set(valueAtom, 2); - set(valueAtom, 3); - set(valueAtom, 4); - set(valueAtom, 5); - }); - - store.set(valueSetAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : called set of ${valueSetAtom}`], - [`initialized value of ${valueAtom} to 1`, { value: 1 }], - [ - `changed value of ${valueAtom} 4 times from 1 to 5`, - { oldValues: [1, 2, 3, 4], newValue: 5 }, - ], - ]); - }); - - it('should log merged atom value changes as is', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: false, - stringifyValues: false, - }); - - const valueAtom = atom(0); - - const valueSetAtom = atom(null, (get, set) => { - set(valueAtom, 1); - set(valueAtom, 2); - set(valueAtom, 3); - set(valueAtom, 4); - set(valueAtom, 5); - }); - - store.set(valueSetAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : called set of ${valueSetAtom}`], - [`initialized value of ${valueAtom} to`, 1], - [`changed value of ${valueAtom} 4 times from`, [1, 2, 3, 4], `to`, 5], - ]); - }); - - it('should log merged atom value changes in colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const valueAtom = atom(0); - - const valueSetAtom = atom(null, (get, set) => { - set(valueAtom, 1); - set(valueAtom, 2); - set(valueAtom, 3); - set(valueAtom, 4); - set(valueAtom, 5); - }); - - const valueAtomNumber = /atom(\d+)(.*)/.exec(valueAtom.toString())?.[1]; - const valueSetAtomNumber = /atom(\d+)(.*)/.exec(valueSetAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(valueAtomNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(valueSetAtomNumber!))).toBeTruthy(); - - store.set(valueSetAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %ccalled set %cof %catom%c${valueSetAtomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #E69F00; font-weight: bold;', // called set - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${valueAtomNumber} %cto %c1`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 1 - { value: 1 }, - ], - [ - `%cchanged value %cof %catom%c${valueAtomNumber} %c4 %ctimes %cfrom %c1 %cto %c5`, - 'color: #56B4E9; font-weight: bold;', // changed value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: default; font-weight: normal;', // 4 - 'color: #757575; font-weight: normal;', // times - 'color: #757575; font-weight: normal;', // from - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 5 - { newValue: 5, oldValues: [1, 2, 3, 4] }, - ], - ]); - }); - - it('should not crash when logging an atom with a circular value', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const circularValue = {} as { self: unknown }; - circularValue.self = circularValue; - const circularAtom = atom(circularValue); - - store.get(circularAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${circularAtom}`], - [`initialized value of ${circularAtom} to [Circular]`, { value: circularValue }], - ]); - }); - }); - - describe('dependencies', () => { - it('should log dependencies', () => { - bindAtomsLoggerToStore(store, defaultOptions); - const valueAtom = atom(1); - const multiplyAtom = atom(2); - const resultAtom = atom((get) => get(valueAtom) * get(multiplyAtom)); - store.sub(resultAtom, vi.fn()); - store.set(valueAtom, 2); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${resultAtom}`], - [`initialized value of ${valueAtom} to 1`, { value: 1 }], - [`initialized value of ${multiplyAtom} to 2`, { value: 2 }], - [ - `initialized value of ${resultAtom} to 2`, - { dependencies: [`${valueAtom}`, `${multiplyAtom}`], value: 2 }, - ], - [`mounted ${valueAtom}`, { value: 1 }], - [`mounted ${multiplyAtom}`, { value: 2 }], - [`mounted ${resultAtom}`, { dependencies: [`${valueAtom}`, `${multiplyAtom}`], value: 2 }], - [`transaction 2 : set value of ${valueAtom} to 2`, { value: 2 }], - [ - `changed value of ${valueAtom} from 1 to 2`, - { dependents: [`${resultAtom}`], newValue: 2, oldValue: 1 }, - ], - [ - `changed value of ${resultAtom} from 2 to 4`, - { dependencies: [`${valueAtom}`, `${multiplyAtom}`], newValue: 4, oldValue: 2 }, - ], - ]); - }); - - it('should not log dependencies if the only dependencies are private', () => { - bindAtomsLoggerToStore(store, defaultOptions); - const privateAtom = atom(0); - privateAtom.debugPrivate = true; - const publicAtom = atom((get) => get(privateAtom) + 1); - store.get(publicAtom); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${publicAtom}`], - [`initialized value of ${publicAtom} to 1`, { value: 1 }], - ]); - }); - - it('should log when an atom dependencies have changed', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - return get(aAtom); - } else { - return get(bAtom); - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${toggleAtom} to false`, { value: false }], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [ - `initialized value of ${testAtom} to 1`, - { dependencies: [`${toggleAtom}`, `${aAtom}`], value: 1 }, - ], - [`mounted ${toggleAtom}`, { value: false }], - [`mounted ${aAtom}`, { value: 1 }], - [ - `mounted ${testAtom}`, - { - dependencies: [`${toggleAtom}`, `${aAtom}`], - value: 1, - }, - ], - - [`transaction 2 : set value of ${toggleAtom}`], - [ - `changed value of ${toggleAtom} from false to true`, - { dependents: [`${testAtom}`], newValue: true, oldValue: false }, - ], - [`initialized value of ${bAtom} to 2`, { value: 2 }], - [ - `changed dependencies of ${testAtom}`, - { - oldDependencies: [`${toggleAtom}`, `${aAtom}`], - newDependencies: [`${toggleAtom}`, `${bAtom}`], - }, - ], - [ - `changed value of ${testAtom} from 1 to 2`, - { - dependencies: [`${toggleAtom}`, `${bAtom}`], - newValue: 2, - oldValue: 1, - }, - ], - [`mounted ${bAtom}`, { value: 2 }], - [`unmounted ${aAtom}`], - ]); - }); - - it('should not track atom dependencies of private atoms', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - bAtom.debugPrivate = true; - const cAtom = atom((get) => { - get(aAtom); - get(bAtom); - }); - cAtom.debugPrivate = true; - - store.sub(cAtom, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [`mounted ${aAtom}`, { value: 1 }], - ]); - - const storeData = (store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]; - expect(storeData.dependenciesMap.has(aAtom)).toBeTruthy(); - expect(storeData.dependenciesMap.has(bAtom)).toBeFalsy(); - expect(storeData.dependenciesMap.has(cAtom)).toBeFalsy(); - expect(storeData.prevTransactionDependenciesMap.has(aAtom)).toBeTruthy(); - expect(storeData.prevTransactionDependenciesMap.has(bAtom)).toBeFalsy(); - expect(storeData.prevTransactionDependenciesMap.has(cAtom)).toBeFalsy(); - }); - - it('should update value-event dependencies when a dependency is removed and a value change already exists in the transaction', () => { - // Covers add-event-to-transaction.ts:102 — the else-if (existingEvent.dependencies !== undefined) branch - // when a removedDependency event is processed and a non-dependenciesChanged event with - // dependencies also exists in the current transaction - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - // testAtom depends on aAtom and optionally bAtom, and its value changes when toggle changes - const testAtom = atom((get) => { - const toggle = get(toggleAtom); - if (!toggle) { - return get(aAtom) + get(bAtom); - } - return get(aAtom); - }); - - store.sub(testAtom, vi.fn()); - store.set(aAtom, 10); // triggers value change event for testAtom with current deps - store.set(toggleAtom, true); // triggers dep removal - - vi.runAllTimers(); - - // The value-change events for testAtom should reflect the final dependency set - expect(consoleMock.log.mock.calls).toEqual( - expect.arrayContaining([ - expect.arrayContaining([expect.stringContaining(`changed dependencies of ${testAtom}`)]), - ]), - ); - }); - - it('should add a dependenciesChanged event when a dep is first removed (no prior dependenciesChanged in transaction)', () => { - // Covers add-event-to-transaction.ts:95-97 (currentTransaction=null / no prior dep event) - // and the pure-deletion case where no hasExistingDepsChangedEvent - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - get(bAtom); - } else { - get(aAtom); - } - return null; - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, true); // removes bAtom dep in its own transaction - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual( - expect.arrayContaining([ - expect.arrayContaining([expect.stringContaining(`changed dependencies of ${testAtom}`)]), - ]), - ); - }); - - it('should log when an atom dependencies are removed', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - get(bAtom); - return; - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [`initialized value of ${bAtom} to 2`, { value: 2 }], - [ - `initialized value of ${testAtom} to undefined`, - { dependencies: [`${aAtom}`, `${bAtom}`], value: undefined }, - ], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${bAtom}`, { value: 2 }], - [`mounted ${testAtom}`, { dependencies: [`${aAtom}`, `${bAtom}`], value: undefined }], - - [`transaction 2`], - [ - `changed dependencies of ${testAtom}`, - { oldDependencies: [`${aAtom}`, `${bAtom}`], newDependencies: [] }, - ], - [`unmounted ${aAtom}`], - [`unmounted ${bAtom}`], - ]); - }); - - it('should log when an atom dependencies are added', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - return; - } else { - get(aAtom); - get(bAtom); - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${testAtom} to undefined`, { value: undefined }], - [`mounted ${testAtom}`, { value: undefined }], - - [`transaction 2`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [`initialized value of ${bAtom} to 2`, { value: 2 }], - [ - `changed dependencies of ${testAtom}`, - { oldDependencies: [], newDependencies: [`${aAtom}`, `${bAtom}`] }, - ], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${bAtom}`, { value: 2 }], - ]); - }); - - it('should not log atom dependencies changes if the new dependencies are private', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - bAtom.debugPrivate = true; - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - } else { - get(aAtom); - get(bAtom); // bAtom is added but is private - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [ - `initialized value of ${testAtom} to undefined`, - { dependencies: [`${aAtom}`], value: undefined }, - ], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${testAtom}`, { dependencies: [`${aAtom}`], value: undefined }], - ]); - }); - - it('should not log when a private atom dependency is removed', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const privateAtom = atom(2); - privateAtom.debugPrivate = true; - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - get(privateAtom); // private dep that will be removed - } else { - get(aAtom); // only aAtom remains - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - // Visible deps stay the same ([aAtom]) – only the private dep was removed → no dep change logged - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [ - `initialized value of ${testAtom} to undefined`, - { dependencies: [`${aAtom}`], value: undefined }, - ], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${testAtom}`, { dependencies: [`${aAtom}`], value: undefined }], - ]); - }); - - it('should log atom dependencies without duplicated atoms', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - get(aAtom); - } else { - get(aAtom); - get(aAtom); - get(bAtom); - get(bAtom); - get(bAtom); - } - }); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [ - `initialized value of ${testAtom} to undefined`, - { dependencies: [`${aAtom}`], value: undefined }, - ], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${testAtom}`, { dependencies: [`${aAtom}`], value: undefined }], - - [`transaction 2`], - [`initialized value of ${bAtom} to 2`, { value: 2 }], - [ - `changed dependencies of ${testAtom}`, - { oldDependencies: [`${aAtom}`], newDependencies: [`${aAtom}`, `${bAtom}`] }, - ], - [`mounted ${bAtom}`, { value: 2 }], - ]); - }); - - it('should log atom dependencies changed in colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const aAtom = atom(1); - const bAtom = atom(2); - const toggleAtom = atom(false); - toggleAtom.debugPrivate = true; - const testAtom = atom((get) => { - if (!get(toggleAtom)) { - get(aAtom); - } else { - get(bAtom); - } - }); - - const testAtomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - const aAtomNumber = /atom(\d+)(.*)/.exec(aAtom.toString())?.[1]; - const bAtomNumber = /atom(\d+)(.*)/.exec(bAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(testAtomNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(aAtomNumber!))).toBeTruthy(); - expect(Number.isInteger(parseInt(bAtomNumber!))).toBeTruthy(); - - store.sub(testAtom, vi.fn()); - store.set(toggleAtom, (prev) => !prev); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %csubscribed %cto %catom%c${testAtomNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // : - `color: #009E73; font-weight: bold;`, // subscribed - `color: #757575; font-weight: normal;`, // to - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 4 - ], - [ - `%cinitialized value %cof %catom%c${aAtomNumber} %cto %c1`, - `color: #0072B2; font-weight: bold;`, // initialized value - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // 1 - { value: 1 }, - ], - [ - `%cinitialized value %cof %catom%c${testAtomNumber} %cto %cundefined`, - `color: #0072B2; font-weight: bold;`, // initialized value - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 4 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // undefined - { dependencies: [`atom${aAtomNumber}`], value: undefined }, - ], - [ - `%cmounted %catom%c${aAtomNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - { value: 1 }, - ], - [ - `%cmounted %catom%c${testAtomNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 4 - { dependencies: [`atom${aAtomNumber}`], value: undefined }, - ], - [ - `%ctransaction %c2`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 2 - ], - [ - `%cinitialized value %cof %catom%c${bAtomNumber} %cto %c2`, - `color: #0072B2; font-weight: bold;`, // initialized value - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - `color: #757575; font-weight: normal;`, // to - `color: default; font-weight: normal;`, // 2 - { value: 2 }, - ], - [ - `%cchanged dependencies %cof %catom%c${testAtomNumber}`, - `color: #E69F00; font-weight: bold;`, // changed dependencies - `color: #757575; font-weight: normal;`, // of - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 4 - { newDependencies: [`atom${bAtomNumber}`], oldDependencies: [`atom${aAtomNumber}`] }, - ], - [ - `%cmounted %catom%c${bAtomNumber}`, - `color: #009E73; font-weight: bold;`, // mounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 2 - { value: 2 }, - ], - [ - `%cunmounted %catom%c${aAtomNumber}`, - `color: #D55E00; font-weight: bold;`, // unmounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - ]); - }); - }); - - describe('dependents', () => { - it('should not log dependents when not mounted', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom((get) => get(aAtom) * 2); - - store.get(bAtom); // store.get does not mount the atom - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${bAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [`initialized value of ${bAtom} to 2`, { dependencies: [`${aAtom}`], value: 2 }], - ]); - }); - - it('should log dependents when mounted', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const aAtom = atom(1); - const bAtom = atom((get) => get(aAtom) * 2); - - store.sub(bAtom, vi.fn()); // store.sub mounts the atom - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${bAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], - [`initialized value of ${bAtom} to 2`, { value: 2, dependencies: [`${aAtom}`] }], - [`mounted ${aAtom}`, { value: 1 }], - [`mounted ${bAtom}`, { value: 2, dependencies: [`${aAtom}`] }], - ]); - }); - - it('should log dependents after dependent is initialized', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const firstAtom = atom('first'); - const secondAtom = atom('second'); - const resultAtom = atom((get) => get(firstAtom) + ' ' + get(secondAtom)); - - // secondAtom doesn't have yet dependents yet since resultAtom is not mounted yet - store.get(secondAtom); - - // secondAtom should have dependents now - store.sub(resultAtom, vi.fn()); - - // change his value to trigger the log - store.set(secondAtom, '2nd'); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${secondAtom}`], - [`initialized value of ${secondAtom} to "second"`, { value: 'second' }], - - [`transaction 2 : subscribed to ${resultAtom}`], - [`initialized value of ${firstAtom} to "first"`, { value: 'first' }], - [ - `initialized value of ${resultAtom} to "first second"`, - { - dependencies: [`${firstAtom}`, `${secondAtom}`], - value: 'first second', - }, - ], - [`mounted ${firstAtom}`, { value: 'first' }], - [`mounted ${secondAtom}`, { value: 'second' }], - [ - `mounted ${resultAtom}`, - { dependencies: [`${firstAtom}`, `${secondAtom}`], value: 'first second' }, - ], - - [`transaction 3 : set value of ${secondAtom} to "2nd"`, { value: '2nd' }], - [ - `changed value of ${secondAtom} from "second" to "2nd"`, - { - dependents: [`${resultAtom}`], // here he is - newValue: '2nd', - oldValue: 'second', - }, - ], - [ - `changed value of ${resultAtom} from "first second" to "first 2nd"`, - { - dependencies: [`${firstAtom}`, `${secondAtom}`], - newValue: 'first 2nd', - oldValue: 'first second', - }, - ], - ]); - }); - }); - - describe('transactions', () => { - it('should log transactions details triggered by public atoms', () => { - bindAtomsLoggerToStore(store, defaultOptions); - const publicAtom = atom(0); - const notPrivateSetAtom = atom(null, (get, set) => { - set(publicAtom, 1); - }); - store.set(notPrivateSetAtom); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : called set of ${notPrivateSetAtom}`], - [`initialized value of ${publicAtom} to 1`, { value: 1 }], - ]); - }); - - it('should not log transactions details triggered by private atoms', () => { - bindAtomsLoggerToStore(store, defaultOptions); - const publicAtom = atom(0); - const privateSetAtom = atom(null, (get, set) => { - set(publicAtom, 1); - }); - privateSetAtom.debugPrivate = true; - store.set(privateSetAtom); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${publicAtom} to 1`, { value: 1 }], - ]); - }); - - it('should not log transactions with only private atoms', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, shouldShowPrivateAtoms: false }); - const privateAtom = atom(0); - privateAtom.debugPrivate = true; - const privateSetAtom = atom(null, (get, set) => { - set(privateAtom, 1); - }); - privateSetAtom.debugPrivate = true; - store.set(privateSetAtom); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([]); - }); - - it('should not log transactions without events', () => { - bindAtomsLoggerToStore(store, defaultOptions); - const testSetAtom = atom(null, () => { - // No events - }); - store.set(testSetAtom); - vi.runAllTimers(); - expect(consoleMock.log.mock.calls).toEqual([]); - }); - - it('should log changes made outside of transactions inside an unknown transaction', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(0); - const setTestAtom = atom(null, (get, set) => { - setTimeout(() => { - set(testAtom, 42); // Outside of store.set transaction - }, 1000); - }); - store.set(setTestAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], // No transaction name since it's an unknown transaction - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - - it('should debounce events in the same transaction', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom('trans-1.0'); - const setTestAtom = atom(null, (get, set) => { - setTimeout(() => { - // This is a new unknown transaction - set(testAtom, 'trans-1.1'); - vi.advanceTimersByTime(50); // debounce - set(testAtom, 'trans-1.2'); - vi.advanceTimersByTime(50); // debounce - set(testAtom, 'trans-1.3'); - - // Will be in another transaction if >= 250ms - vi.advanceTimersByTime(250); - set(testAtom, 'trans-2.1'); - }, 1000); - }); - store.set(setTestAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1`], - [`initialized value of ${testAtom} to "trans-1.1"`, { value: 'trans-1.1' }], - [ - `changed value of ${testAtom} 2 times from "trans-1.1" to "trans-1.3"`, - { - newValue: 'trans-1.3', - oldValues: ['trans-1.1', 'trans-1.2'], - }, - ], - - [`transaction 2`], - [ - `changed value of ${testAtom} from "trans-1.3" to "trans-2.1"`, - { newValue: 'trans-2.1', oldValue: 'trans-1.3' }, - ], - ]); - }); - - describe('requestIdleCallback', () => { - it('should schedule and log queued transactions by using requestIdleCallback', () => { - const requestIdleCallbacks: (() => void)[] = []; - const requestIdleCallbackMockFn = vi.fn((cb: IdleRequestCallback) => { - requestIdleCallbacks.push(() => { - cb({ didTimeout: false, timeRemaining: () => 50 }); - }); - return 1; - }); - globalThis.requestIdleCallback = requestIdleCallbackMockFn; - onTestFinished(() => { - delete (globalThis as Partial).requestIdleCallback; - }); - - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(0); - - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); - expect(consoleMock.log.mock.calls).toEqual([]); - - // Run all transactions - store.get(testAtom); - store.set(testAtom, 1); - store.set(testAtom, 2); - vi.runAllTimers(); - expect(requestIdleCallbackMockFn).toHaveBeenCalledOnce(); // First transaction scheduled - requestIdleCallbackMockFn.mockClear(); - expect(consoleMock.log.mock.calls).toEqual([]); // Nothing logged yet - - requestIdleCallbacks.shift()!(); // Run the queued transactions - vi.runAllTimers(); - expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); // No more transactions scheduled - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - [`transaction 2 : set value of ${testAtom} to 1`, { value: 1 }], - [`changed value of ${testAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], - [`transaction 3 : set value of ${testAtom} to 2`, { value: 2 }], - [`changed value of ${testAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], - ]); - }); - }); - - it('should merge nested direct store calls', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - onTestFinished(() => { - consoleWarnSpy.mockRestore(); - }); - - bindAtomsLoggerToStore(store, defaultOptions); - - const otherAtom1 = atom(0); - const otherAtom2 = atom(0); - const otherAtom3 = atom(0); - - const testAtomCallback = (otherAtom: PrimitiveAtom) => () => { - store.get(otherAtom); // Nested store.get call - store.set(otherAtom, 2); // Nested store.set call - store.sub(otherAtom, vi.fn()); // Nested store.sub call - }; - - const testAtom1 = atom(testAtomCallback(otherAtom1), testAtomCallback(otherAtom1)); - const testAtom2 = atom(testAtomCallback(otherAtom2), testAtomCallback(otherAtom2)); - const testAtom3 = atom(testAtomCallback(otherAtom3), testAtomCallback(otherAtom3)); - - store.get(testAtom1); - store.set(testAtom2); - store.sub(testAtom3, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - // Nested inside store.get - [`transaction 1 : retrieved value of ${testAtom1}`], - // `- Nested store.get transaction - [`initialized value of ${otherAtom1} to 0`, { value: 0 }], - // `- Nested store.set transaction - [`changed value of ${otherAtom1} from 0 to 2`, { newValue: 2, oldValue: 0 }], - // `- Nested store.sub transaction - [`mounted ${otherAtom1}`, { value: 2 }], - [`initialized value of ${testAtom1} to undefined`, { value: undefined }], - - // Nested inside store.set - [`transaction 2 : called set of ${testAtom2}`], - // `- Nested store.get transaction - [`initialized value of ${otherAtom2} to 0`, { value: 0 }], - // `- Nested store.set transaction - [`changed value of ${otherAtom2} from 0 to 2`, { newValue: 2, oldValue: 0 }], - // `- Nested store.sub transaction - [`mounted ${otherAtom2}`, { value: 2 }], - - // Nested inside store.sub - [`transaction 3 : subscribed to ${testAtom3}`], - // `- Nested store.get transaction - [`initialized value of ${otherAtom3} to 0`, { value: 0 }], - // `- Nested store.set transaction - [`changed value of ${otherAtom3} from 0 to 2`, { newValue: 2, oldValue: 0 }], - // `- Nested store.sub transaction - [`mounted ${otherAtom3}`, { value: 2 }], - [`initialized value of ${testAtom3} to undefined`, { value: undefined }], - [`mounted ${testAtom3}`, { value: undefined }], - ]); - - // Jotai should warns about direct store mutations inside atoms - expect(consoleWarnSpy.mock.calls).toEqual([ - ['Detected store mutation during atom read. This is not supported.'], - ['Detected store mutation during atom read. This is not supported.'], - ]); - }); - - describe('combinations of transaction options', () => { - const testCases = [ - // 0000 - all false - { - binary: '0000', - showTransactionNumber: false, - showTransactionEventsCount: false, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => `retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%cretrieved value %cof %catom%c${atomNumber}`, - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0001 - only showTransactionLocaleTime true - { - binary: '0001', - showTransactionNumber: false, - showTransactionEventsCount: false, - showTransactionElapsedTime: false, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => `00:00:00 : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0010 - only showTransactionElapsedTime true - { - binary: '0010', - showTransactionNumber: false, - showTransactionEventsCount: false, - showTransactionElapsedTime: true, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => `345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0011 - showTransactionElapsedTime and showTransactionLocaleTime true - { - binary: '0011', - showTransactionNumber: false, - showTransactionEventsCount: false, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => `00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0100 - only showTransactionEventsCount true - { - binary: '0100', - showTransactionNumber: false, - showTransactionEventsCount: true, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => `1 event : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0101 - showTransactionEventsCount and showTransactionLocaleTime true - { - binary: '0101', - showTransactionNumber: false, - showTransactionEventsCount: true, - showTransactionElapsedTime: false, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => `1 event - 00:00:00 : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c1 event %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0110 - showTransactionEventsCount and showTransactionElapsedTime true - { - binary: '0110', - showTransactionNumber: false, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => `1 event - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c1 event %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 0111 - showTransactionEventsCount, showTransactionElapsedTime and showTransactionLocaleTime true - { - binary: '0111', - showTransactionNumber: false, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => - `1 event - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%c1 event %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1000 - only showTransactionNumber true - { - binary: '1000', - showTransactionNumber: true, - showTransactionEventsCount: false, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => `transaction 1 : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1001 - showTransactionNumber and showTransactionLocaleTime true - { - binary: '1001', - showTransactionNumber: true, - showTransactionEventsCount: false, - showTransactionElapsedTime: false, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => - `transaction 1 - 00:00:00 : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1010 - showTransactionNumber and showTransactionElapsedTime true - { - binary: '1010', - showTransactionNumber: true, - showTransactionEventsCount: false, - showTransactionElapsedTime: true, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => - `transaction 1 - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1011 - showTransactionNumber, showTransactionElapsedTime and showTransactionLocaleTime true - { - binary: '1011', - showTransactionNumber: true, - showTransactionEventsCount: false, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => - `transaction 1 - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1100 - showTransactionNumber and showTransactionEventsCount true - { - binary: '1100', - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionElapsedTime: false, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => - `transaction 1 - 1 event : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c1 event %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1101 - showTransactionNumber, showTransactionEventsCount and showTransactionLocaleTime true - { - binary: '1101', - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionElapsedTime: false, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => - `transaction 1 - 1 event - 00:00:00 : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c1 event %c- %c00:00:00 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1110 - showTransactionNumber, showTransactionEventsCount and showTransactionElapsedTime true - { - binary: '1110', - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - showTransactionLocaleTime: false, - expected: (testAtom: AnyAtom) => - `transaction 1 - 1 event - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c1 event %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - // 1111 - all true - { - binary: '1111', - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionElapsedTime: true, - showTransactionLocaleTime: true, - expected: (testAtom: AnyAtom) => - `transaction 1 - 1 event - 00:00:00 - 345.00 ms : retrieved value of ${testAtom}`, - expectedColors: (testAtom: AnyAtom, atomNumber: string) => [ - `%ctransaction %c1 %c- %c1 event %c- %c00:00:00 %c- %c345.00 ms %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 1 event - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 00:00:00 - 'color: #757575; font-weight: normal;', // - - 'color: #757575; font-weight: normal;', // 345.00 ms - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - }, - ]; - - it.each(testCases)( - 'should show correctly with options $binary (number=$showTransactionNumber, events=$showTransactionEventsCount, time=$showTransactionElapsedTime, locale=$showTransactionLocaleTime)', - ({ - showTransactionNumber, - showTransactionEventsCount, - showTransactionElapsedTime, - showTransactionLocaleTime, - expected, - }) => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - showTransactionNumber, - showTransactionEventsCount, - showTransactionElapsedTime, - showTransactionLocaleTime, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(345); // Fake the delay of the transaction - return 0; - }); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [expected(testAtom)], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }, - ); - - it.each(testCases)( - 'should show correctly with colors with options $binary (number=$showTransactionNumber, events=$showTransactionEventsCount, time=$showTransactionElapsedTime, locale=$showTransactionLocaleTime)', - ({ - showTransactionNumber, - showTransactionEventsCount, - showTransactionElapsedTime, - showTransactionLocaleTime, - expectedColors, - }) => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - showTransactionNumber, - showTransactionEventsCount, - showTransactionElapsedTime, - showTransactionLocaleTime, - }); - - const testAtom = atom(() => { - vi.advanceTimersByTime(345); // Fake the delay of the transaction - return 0; - }); - store.get(testAtom); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - expectedColors(testAtom, atomNumber!), - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }, - ); - }); - }); - - describe('mounting', () => { - it('should unsubscribe while inside a transaction without starting a new one', () => { - // Covers on-store-sub.ts lines 40-51: doStartTransaction=false in onStoreUnsubscribe - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(0); - let capturedUnsubscribe: (() => void) | undefined; - - const triggerAtom = atom(null, (_get, set) => { - // Subscribe and immediately unsubscribe from within a transaction (set call) - capturedUnsubscribe = store.sub(testAtom, vi.fn()); - set(testAtom, 1); - }); - - store.set(triggerAtom); - - vi.runAllTimers(); - - expect(capturedUnsubscribe).toBeDefined(); - - // Now unsubscribe from within another transaction (isInsideTransaction=true) - const unsubAtom = atom(null, () => { - capturedUnsubscribe!(); - }); - store.set(unsubAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls.length).toBeGreaterThan(0); - }); - - it('should log mounted and unmounted atoms', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - - const unmount = store.sub(testAtom, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - [`mounted ${testAtom}`, { value: 42 }], - ]); - - unmount(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - [`mounted ${testAtom}`, { value: 42 }], - - [`transaction 2 : unsubscribed from ${testAtom}`], - [`unmounted ${testAtom}`], - ]); - }); - - it('should log mounted and unmounted atoms in colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const testAtom = atom(42); - - const unmount = store.sub(testAtom, vi.fn()); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %csubscribed %cto %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #009E73; font-weight: bold;', // subscribed - 'color: #757575; font-weight: normal;', // to - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c42`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 42 - { value: 42 }, - ], - [ - `%cmounted %catom%c${atomNumber}`, - 'color: #009E73; font-weight: bold;', // mounted - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - { value: 42 }, - ], - ]); - - vi.clearAllMocks(); - - unmount(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c2 %c: %cunsubscribed %cfrom %catom%c${atomNumber}`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 2 - `color: #757575; font-weight: normal;`, // : - `color: #D55E00; font-weight: bold;`, // unsubscribed - `color: #757575; font-weight: normal;`, // from - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - [ - `%cunmounted %catom%c${atomNumber}`, - `color: #D55E00; font-weight: bold;`, // unmounted - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - ]); - }); - - it('should log atom value when mounted', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(42); - - store.sub(testAtom, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - [`mounted ${testAtom}`, { value: 42 }], - ]); - }); - - it('should log atom promise value when mounted', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const testAtom = atom(() => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(42); - }, 1000); - }); - }); - - void store.get(testAtom); // resolves the promise - await vi.advanceTimersByTimeAsync(1000); - - store.sub(testAtom, vi.fn()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`pending initial promise of ${testAtom}`], - - [`transaction 2 : resolved promise of ${testAtom}`], - [`resolved initial promise of ${testAtom} to 42`, { value: 42 }], - - [`transaction 3 : subscribed to ${testAtom}`], - [`mounted ${testAtom}`, { value: 42 }], - ]); - }); - }); - - describe('setters', () => { - it('should log default atom setter', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const simpleAtom = atom(0); - store.set(simpleAtom, 1); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : set value of ${simpleAtom} to 1`, { value: 1 }], - [`initialized value of ${simpleAtom} to 1`, { value: 1 }], - ]); - }); - - it('should log default atom setter in colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const simpleAtom = atom(0); - store.set(simpleAtom, 1); - - const atomNumber = /atom(\d+)(.*)/.exec(simpleAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cset value %cof %catom%c${atomNumber} %cto %c1`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #E69F00; font-weight: bold;', // set value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 1 - { value: 1 }, - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c1`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 1 - { value: 1 }, - ], - ]); - }); - - it('should log custom atom setter', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const valueAtom = atom(0); - const oneSetAtom = atom(null, (get, set) => { - set(valueAtom, 1); - }); - const twoSetAtom = atom(null, (get, set, args: { newValue: number }) => { - set(valueAtom, args.newValue); - }); - const threeSetAtom = atom(null, (get, set) => { - set(valueAtom, 3); - return `myReturnValue-3`; - }); - const fourSetAtom = atom(null, (get, set, args: { newValue: number }, otherArg: string) => { - set(valueAtom, args.newValue); - return `myOtherReturnValue-${args.newValue}-${otherArg}`; - }); - - store.get(valueAtom); - - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - const one = store.set(oneSetAtom); - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - const two = store.set(twoSetAtom, { newValue: 2 }); - const three = store.set(threeSetAtom); - const four = store.set(fourSetAtom, { newValue: 4 }, 'otherArg'); - - expect(one).toBe(undefined); - expect(two).toBe(undefined); - expect(three).toBe('myReturnValue-3'); - expect(four).toBe('myOtherReturnValue-4-otherArg'); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${valueAtom}`], - [`initialized value of ${valueAtom} to 0`, { value: 0 }], - - [`transaction 2 : called set of ${oneSetAtom}`], - [`changed value of ${valueAtom} from 0 to 1`, { newValue: 1, oldValue: 0 }], - - [ - `transaction 3 : called set of ${twoSetAtom} with {"newValue":2}`, - { args: [{ newValue: 2 }] }, - ], - [`changed value of ${valueAtom} from 1 to 2`, { newValue: 2, oldValue: 1 }], - - [ - `transaction 4 : called set of ${threeSetAtom} and returned "myReturnValue-3"`, - { result: 'myReturnValue-3' }, - ], - [`changed value of ${valueAtom} from 2 to 3`, { newValue: 3, oldValue: 2 }], - - [ - `transaction 5 : called set of ${fourSetAtom} with [{"newValue":4},"otherArg"] and returned "myOtherReturnValue-4-otherArg"`, - { - args: [{ newValue: 4 }, 'otherArg'], - result: 'myOtherReturnValue-4-otherArg', - }, - ], - [`changed value of ${valueAtom} from 3 to 4`, { newValue: 4, oldValue: 3 }], - ]); - }); - - it('should log default atom setter with previous state function', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const simpleAtom = atom(0); - store.set(simpleAtom, (prev) => prev + 1); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : set value of ${simpleAtom}`], - [`initialized value of ${simpleAtom} to 0`, { value: 0 }], - [`changed value of ${simpleAtom} from 0 to 1`, { oldValue: 0, newValue: 1 }], - ]); - }); - }); - - describe('complex graphs', () => { - it('should log async atoms with dependencies and dependents', async () => { - bindAtomsLoggerToStore(store, defaultOptions); - - const firstAtom = atom('first'); - const secondAtom = atom('second'); - const thirdAsyncAtom = atom>(async (get) => { - const third = get(firstAtom) + ' ' + 'third'; - return new Promise((resolve) => { - setTimeout(() => { - resolve(third); - }, 500); - }); - }); - - // loadable uses unwrap internally and since loadable doesn't expose it, we use a regex to match it - const unwrappedThirdAsyncAtomDebugLabelRegex = new RegExp(`atom\\d+`); - - const resultAtom = atom((get) => { - const second = get(secondAtom); - const third = get(loadable(thirdAsyncAtom)); - return `${second} ${third.state === 'hasData' ? third.data : third.state}`; - }); - - store.sub(resultAtom, vi.fn()); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : subscribed to ${resultAtom}`], - - // result <-- second - [`initialized value of ${secondAtom} to "second"`, { value: 'second' }], - // result <-- loadable(thirdAsync) <-- thirdAsync <-- first - [`initialized value of ${firstAtom} to "first"`, { value: 'first' }], - // result <-- loadable(thirdAsync) <-- thirdAsync - [`pending initial promise of ${thirdAsyncAtom}`, { dependencies: [`${firstAtom}`] }], - // result <-- loadable(thirdAsync) - [ - expect.stringMatching( - new RegExp( - `initialized value of ${unwrappedThirdAsyncAtomDebugLabelRegex.source} to {"state":"loading"}`, - ), - ), - { value: { state: 'loading' } }, - ], - [ - `initialized value of ${loadable(thirdAsyncAtom)} to {"state":"loading"}`, - { - dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], - value: { state: 'loading' }, - }, - ], - // result - [ - `initialized value of ${resultAtom} to "second loading"`, - { - dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], - value: 'second loading', - }, - ], - [`mounted ${secondAtom}`, { value: 'second' }], - [`mounted ${firstAtom}`, { pendingPromises: [`${thirdAsyncAtom}`], value: 'first' }], - [`mounted ${thirdAsyncAtom}`, { dependencies: [`${firstAtom}`] }], - [ - expect.stringMatching( - new RegExp(`mounted ${unwrappedThirdAsyncAtomDebugLabelRegex.source}`), - ), - { value: { state: 'loading' } }, - ], - [ - `mounted ${loadable(thirdAsyncAtom)}`, - { - dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], - value: { state: 'loading' }, - }, - ], - [ - `mounted ${resultAtom}`, - { - dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], - value: 'second loading', - }, - ], - - [`transaction 2 : resolved promise of ${thirdAsyncAtom}`], - // result <-- loadable(thirdAsync) <-- thirdAsync <-- promise resolved - [ - `resolved initial promise of ${thirdAsyncAtom} to "first third"`, - { dependencies: [`${firstAtom}`], value: 'first third' }, - ], - ['transaction 3'], - [ - expect.stringMatching( - new RegExp( - `changed value of ${unwrappedThirdAsyncAtomDebugLabelRegex.source} from {"state":"loading"} to "first third"`, - ), - ), - { - dependents: [`${loadable(thirdAsyncAtom)}`], - oldValue: { state: 'loading' }, - newValue: 'first third', - }, - ], - // result <-- loadable(thirdAsync) - [ - `changed value of ${loadable(thirdAsyncAtom)} from {"state":"loading"} to {"state":"hasData","data":"first third"}`, - { - dependencies: [expect.stringMatching(unwrappedThirdAsyncAtomDebugLabelRegex)], - dependents: [`${resultAtom}`], - newValue: { data: 'first third', state: 'hasData' }, - oldValue: { state: 'loading' }, - }, - ], - // result - [ - `changed value of ${resultAtom} from "second loading" to "second first third"`, - { - dependencies: [`${secondAtom}`, `${loadable(thirdAsyncAtom)}`], - newValue: 'second first third', - oldValue: 'second loading', - }, - ], - ]); - }); - }); - - describe('colors', () => { - it('should not log colors if formattedOutput is false', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: false, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - }); - - it('should log colors if formattedOutput is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const testAtom = atom(0); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - - it('should log atom name without namespaces with color', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const testAtom = atom(0); - testAtom.debugLabel = 'testAtomWithoutNamespaces'; - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}%c:%ctestAtomWithoutNamespaces`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: default; font-weight: normal;', // testAtomWithoutNamespaces - ], - [ - `%cinitialized value %cof %catom%c${atomNumber}%c:%ctestAtomWithoutNamespaces %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: default; font-weight: normal;', // testAtomWithoutNamespaces - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - - it('should log atom name namespaces with colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - const testAtom = atom(0); - testAtom.debugLabel = 'test/atom/with/namespaces'; - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}%c:%ctest%c/%catom%c/%cwith%c/namespaces`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #757575; font-weight: normal;', // test - 'color: default; font-weight: normal;', // / - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // / - 'color: #757575; font-weight: normal;', // with - 'color: default; font-weight: normal;', // /namespaces - ], - [ - `%cinitialized value %cof %catom%c${atomNumber}%c:%ctest%c/%catom%c/%cwith%c/namespaces %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #757575; font-weight: normal;', // test - 'color: default; font-weight: normal;', // / - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // / - 'color: #757575; font-weight: normal;', // with - 'color: default; font-weight: normal;', // /namespaces - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - - it('should log dark colors with dark colorScheme option', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - colorScheme: 'dark', - }); - - const testAtom = atom(0); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #999999; font-weight: normal;', - 'color: default; font-weight: normal;', - 'color: #999999; font-weight: normal;', - 'color: #009EFA; font-weight: bold;', - 'color: #999999; font-weight: normal;', - 'color: #999999; font-weight: normal;', - 'color: default; font-weight: normal;', - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #009EFA; font-weight: bold;', - 'color: #999999; font-weight: normal;', - 'color: #999999; font-weight: normal;', - 'color: default; font-weight: normal;', - 'color: #999999; font-weight: normal;', - 'color: default; font-weight: normal;', - { value: 0 }, - ], - ]); - }); - - it('should log light colors with light colorScheme option', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - colorScheme: 'light', - }); - - const testAtom = atom(0); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #6E6E6E; font-weight: normal;', - 'color: default; font-weight: normal;', - 'color: #6E6E6E; font-weight: normal;', - 'color: #0072B2; font-weight: bold;', - 'color: #6E6E6E; font-weight: normal;', - 'color: #6E6E6E; font-weight: normal;', - 'color: default; font-weight: normal;', - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', - 'color: #6E6E6E; font-weight: normal;', - 'color: #6E6E6E; font-weight: normal;', - 'color: default; font-weight: normal;', - 'color: #6E6E6E; font-weight: normal;', - 'color: default; font-weight: normal;', - { value: 0 }, - ], - ]); - }); - - it('should log colored stack traces', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - getOwnerStack() { - return `at MyComponentParent (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; - }, - getComponentDisplayName() { - return 'MyComponent'; - }, - }); - - const testAtom = atom(0); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %c[MyComponentParent] %cMyComponent %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #757575; font-weight: normal;', // [MyComponentParent] - 'color: default; font-weight: normal;', // MyComponent - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - - it('should log colored stack traces with hooks', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - getOwnerStack() { - return `at ParentContainer (http://localhost:5173/src/App.tsx?t=1757750948197:31:21) - at App (http://localhost:5173/src/App.tsx?t=1757750948197:108:21)`; - }, - getComponentDisplayName() { - return 'MyComponent'; - }, - }); - - const testAtom = atom(0); - - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %c[App.ParentContainer] %cMyComponent %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #757575; font-weight: normal;', // [App.ParentContainer] - 'color: default; font-weight: normal;', // MyComponent - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - }); - - describe('groups', () => { - it('should group transactions if groupTransactions is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should collapse transaction groups if collapseTransactions is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - collapseTransactions: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ]); - expect(consoleMock.log.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should group events if groupEvents is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupEvents: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([[`initialized value of ${testAtom} to 0`]]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ['value', 0], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should collapse event groups if collapseEvents is true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupEvents: true, - collapseEvents: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`], - ]); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ['value', 0], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should group transactions and events if both groupTransactions and groupEvents are true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - groupEvents: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`], - ]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([['value', 0]]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); - }); - - it('should group collapsed events and transactions if both collapseTransactions and collapseEvents are true', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - groupEvents: true, - collapseTransactions: true, - collapseEvents: true, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`], - ]); - expect(consoleMock.log.mock.calls).toEqual([['value', 0]]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[], []]); - }); - - it('should log collapsed transaction groups even if logger.group is not defined', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - collapseTransactions: true, - logger: { - log: consoleMock.log, - group: undefined, - groupCollapsed: consoleMock.groupCollapsed, - groupEnd: consoleMock.groupEnd, - }, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ]); - expect(consoleMock.log.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should log event groups even if logger.group is not defined', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupEvents: true, - collapseEvents: true, - logger: { - log: consoleMock.log, - group: undefined, - groupCollapsed: consoleMock.groupCollapsed, - groupEnd: consoleMock.groupEnd, - }, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`], - ]); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ['value', 0], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should log transaction groups even if logger.groupCollapsed is not defined', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - collapseTransactions: false, - logger: { - log: consoleMock.log, - group: consoleMock.group, - groupCollapsed: undefined, - groupEnd: consoleMock.groupEnd, - }, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should log event groups even if logger.groupCollapsed is not defined', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupEvents: true, - collapseEvents: false, - logger: { - log: consoleMock.log, - group: consoleMock.group, - groupCollapsed: undefined, - groupEnd: consoleMock.groupEnd, - }, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([[`initialized value of ${testAtom} to 0`]]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - ['value', 0], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([[]]); - }); - - it('should not log transaction and event groups if logger.groupEnd is not defined', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - groupTransactions: true, - groupEvents: true, - logger: { - log: consoleMock.log, - group: consoleMock.group, - groupCollapsed: consoleMock.groupCollapsed, - groupEnd: undefined, - }, - }); - - const testAtom = atom(0); - store.get(testAtom); - - vi.runAllTimers(); - - expect(consoleMock.group.mock.calls).toEqual([]); - expect(consoleMock.groupCollapsed.mock.calls).toEqual([]); - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 0`, { value: 0 }], - ]); - expect(consoleMock.groupEnd.mock.calls).toEqual([]); - }); - }); - - describe('destroyed atoms', () => { - let finalizationRegistryRegisterMock: Mock; - let finalizationRegistryUnregisterMock: Mock; - let registeredCallback: ((heldValue: AtomId) => void) | null; - - beforeEach(() => { - finalizationRegistryRegisterMock = vi.fn(); - finalizationRegistryUnregisterMock = vi.fn(); - registeredCallback = null; - vi.spyOn(globalThis, 'FinalizationRegistry').mockImplementation( - function (callback): FinalizationRegistry { - registeredCallback = callback; - return { - register: finalizationRegistryRegisterMock, - unregister: finalizationRegistryUnregisterMock, - [Symbol.toStringTag]: 'FinalizationRegistry', - }; - }, - ); - }); - - it('should register atoms with FinalizationRegistry for garbage collection tracking', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - expect(finalizationRegistryRegisterMock).not.toHaveBeenCalled(); - - const testAtom = atom(42); - store.get(testAtom); - - expect(finalizationRegistryRegisterMock).toHaveBeenCalled(); - expect(finalizationRegistryRegisterMock.mock.calls).toEqual([ - [testAtom, testAtom.toString()], - ]); - }); - - it('should log when an atom is garbage collected', () => { - bindAtomsLoggerToStore(store, defaultOptions); - - expect(registeredCallback).not.toBeNull(); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - registeredCallback!(testAtom.toString()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - - [`transaction 2`], - [`destroyed ${testAtom}`], - ]); - }); - - it('should log when an atom is garbage collected with colors', () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - }); - - expect(registeredCallback).not.toBeNull(); - - const testAtom = atom(42); - const atomNumber = /atom(\d+)(.*)/.exec(testAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - registeredCallback!(testAtom.toString()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1`, - `color: #757575; font-weight: normal;`, // transaction - `color: default; font-weight: normal;`, // 1 - ], - [ - `%cdestroyed %catom%c${atomNumber}`, - `color: #D55E00; font-weight: bold;`, // destroyed - `color: #757575; font-weight: normal;`, // atom - `color: default; font-weight: normal;`, // 1 - ], - ]); - }); - - it('should not log when an atom is garbage collected if the store is disabled', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, enabled: false }); - - expect(registeredCallback).not.toBeNull(); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - registeredCallback!(testAtom.toString()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([]); - }); - - it('should not log when an atom is garbage collected if the store just got disabled', () => { - bindAtomsLoggerToStore(store, { ...defaultOptions, enabled: true }); - - expect(registeredCallback).not.toBeNull(); - - const testAtom = atom(42); - store.get(testAtom); - - vi.runAllTimers(); - - bindAtomsLoggerToStore(store, { ...defaultOptions, enabled: false }); - - registeredCallback!(testAtom.toString()); - - vi.runAllTimers(); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${testAtom}`], - [`initialized value of ${testAtom} to 42`, { value: 42 }], - ]); - }); - }); - - describe('owner stack and component display name', () => { - it('should show owner stack', async () => { - let stackId = 0; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return `at MyCounterParent${++stackId} (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(stackId).toBe(1); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [MyCounterParent1] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show component display name', async () => { - let displayNameId = 0; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getComponentDisplayName() { - return `MyCounter${++displayNameId}`; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(displayNameId).toBe(1); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : MyCounter1 retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show owner stack and component display name', async () => { - let stackId = 0; - let displayNameId = 0; - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return `at MyCounterParent${++stackId} (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; - }, - getComponentDisplayName() { - return `MyCounter${++displayNameId}`; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(stackId).toBe(1); - expect(displayNameId).toBe(1); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [MyCounterParent1] MyCounter1 retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not show component display name if it is shown at the end of the owner stack components', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return `at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) - at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21)`; - }, - getComponentDisplayName() { - return 'ParentComponent'; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [GrandParentComponent.ParentComponent] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show component display name if it is not shown at the end of the owner stack components', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return `at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) - at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21)`; - }, - getComponentDisplayName() { - return 'GrandParentComponent'; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `transaction 1 : [GrandParentComponent.ParentComponent] GrandParentComponent retrieved value of ${countAtom}`, - ], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should ignore crashes in getOwnerStack', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack: () => { - throw new Error('Error in getOwnerStack'); - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should ignore crashes in getComponentDisplayName', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getComponentDisplayName: () => { - throw new Error('Error in getComponentDisplayName'); - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should ignore undefined owner stack traces', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack: () => { - return undefined; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should ignore null owner stack traces', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack: () => { - return null; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should handle malformed owner stack gracefully', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return `malformed stack trace without proper format`; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should handle empty owner stack', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return ''; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should not call getOwnerStack if not needed', async () => { - const getOwnerStackMock = vi.fn(() => { - return `at MyCounterParent (http://localhost:5173/src/myComponent.tsx?t=1757750948197:31:21)`; - }); - - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack: getOwnerStackMock, - }); - - const countAtom = atom(0); - - expect(getOwnerStackMock).not.toHaveBeenCalled(); - - store.get(countAtom); // 1st call - store.get(countAtom); // should not call again (value already initialized) - store.get(countAtom); - await vi.advanceTimersByTimeAsync(1000); - - expect(getOwnerStackMock).toHaveBeenCalledTimes(1); - - const unSub1 = store.sub(countAtom, vi.fn()); // 2nd call (mounted) - const unSub2 = store.sub(countAtom, vi.fn()); - await vi.advanceTimersByTimeAsync(1000); - - expect(getOwnerStackMock).toHaveBeenCalledTimes(2); - - store.get(countAtom); - const unSub3 = store.sub(countAtom, vi.fn()); - unSub1(); - unSub2(); // still mounted - await vi.advanceTimersByTimeAsync(1000); - - expect(getOwnerStackMock).toHaveBeenCalledTimes(2); - - unSub3(); // 3rd call (unmounted) - await vi.advanceTimersByTimeAsync(1000); - - expect(getOwnerStackMock).toHaveBeenCalledTimes(3); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [MyCounterParent] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - [`transaction 2 : [MyCounterParent] subscribed to ${countAtom}`], - [`mounted ${countAtom}`, { value: 0 }], - [`transaction 3 : [MyCounterParent] unsubscribed from ${countAtom}`], - [`unmounted ${countAtom}`], - ]); - }); - - describe('ownerStackLimit', () => { - const BIG_OWNER_STACK = `at ChildComponent (http://localhost:5173/src/child.tsx:10:21) - at MiddleComponent (http://localhost:5173/src/middle.tsx:20:21) - at ParentComponent (http://localhost:5173/src/parent.tsx:30:21) - at GrandParentComponent (http://localhost:5173/src/grandparent.tsx:40:21) - at RootComponent (http://localhost:5173/src/root.tsx:50:21)`; - - it('should respect ownerStackLimit default value of 2', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [MiddleComponent.ChildComponent] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show all components when ownerStackLimit is -1', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: -1, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `transaction 1 : [RootComponent.GrandParentComponent.ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, - ], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show all components when ownerStackLimit is Infinity', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: Infinity, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `transaction 1 : [RootComponent.GrandParentComponent.ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, - ], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show no components when ownerStackLimit is 0', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: 0, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show only 1 component when ownerStackLimit is 1', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: 1, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [ChildComponent] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should show limited components when ownerStackLimit is 3', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: 3, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `transaction 1 : [ParentComponent.MiddleComponent.ChildComponent] retrieved value of ${countAtom}`, - ], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should handle single component in owner stack', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: 2, - getOwnerStack() { - return `at ChildComponent (http://localhost:5173/src/child.tsx:10:21)`; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [ChildComponent] retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should work with ownerStackLimit and component display name', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - ownerStackLimit: 1, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - getComponentDisplayName() { - return 'CurrentComponent'; - }, - }); - - const countAtom = atom(0); - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [`transaction 1 : [ChildComponent] CurrentComponent retrieved value of ${countAtom}`], - [`initialized value of ${countAtom} to 0`, { value: 0 }], - ]); - }); - - it('should work with ownerStackLimit in colored output', async () => { - bindAtomsLoggerToStore(store, { - ...defaultOptions, - formattedOutput: true, - ownerStackLimit: 1, - getOwnerStack() { - return BIG_OWNER_STACK; - }, - getComponentDisplayName() { - return 'CurrentComponent'; - }, - }); - - const countAtom = atom(0); - const atomNumber = /atom(\d+)(.*)/.exec(countAtom.toString())?.[1]; - expect(Number.isInteger(parseInt(atomNumber!))).toBeTruthy(); - - store.get(countAtom); - - await vi.advanceTimersByTimeAsync(1000); - - expect(consoleMock.log.mock.calls).toEqual([ - [ - `%ctransaction %c1 %c: %c[ChildComponent] %cCurrentComponent %cretrieved value %cof %catom%c${atomNumber}`, - 'color: #757575; font-weight: normal;', // transaction - 'color: default; font-weight: normal;', // 1 - 'color: #757575; font-weight: normal;', // : - 'color: #757575; font-weight: normal;', // [ChildComponent] - 'color: default; font-weight: normal;', // CurrentComponent - 'color: #0072B2; font-weight: bold;', // retrieved value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - ], - [ - `%cinitialized value %cof %catom%c${atomNumber} %cto %c0`, - 'color: #0072B2; font-weight: bold;', // initialized value - 'color: #757575; font-weight: normal;', // of - 'color: #757575; font-weight: normal;', // atom - 'color: default; font-weight: normal;', // atomNumber - 'color: #757575; font-weight: normal;', // to - 'color: default; font-weight: normal;', // 0 - { value: 0 }, - ], - ]); - }); - }); - }); -}); diff --git a/tests/atoms-logger-devtools.test.ts b/tests/jotai-devtools.test.tsx similarity index 63% rename from tests/atoms-logger-devtools.test.ts rename to tests/jotai-devtools.test.tsx index 2951266..60ff247 100644 --- a/tests/atoms-logger-devtools.test.ts +++ b/tests/jotai-devtools.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { renderHook } from '@testing-library/react'; -import { atom, createStore } from 'jotai'; +import { render } from '@testing-library/react'; +import { atom, createStore, useStore } from 'jotai'; import { useAtomsDevtools } from 'jotai-devtools'; import type { Store } from 'jotai/vanilla/store'; import { @@ -14,9 +14,9 @@ import { vi, } from 'vitest'; -import { isAtomsLoggerBoundToStore } from '../src/bind-atoms-logger-to-store.js'; -import { bindAtomsLoggerToStore, useAtomsLogger } from '../src/index.js'; -import type { AtomsLoggerOptions } from '../src/types/atoms-logger.js'; +import { consoleFormatter } from '../src/formatters/console/index.js'; +import { AtomLoggerProvider, createLoggedStore, isLoggedStore } from '../src/index.js'; +import type { AtomLoggerOptions } from '../src/vanilla/types/options.js'; function isDevtoolsStore(store: Store): boolean { return 'get_internal_weak_map' in store; @@ -50,33 +50,44 @@ afterEach(() => { mockDate.mockRestore(); }); -describe('useAtomsLogger', () => { +describe('AtomLoggerProvider', () => { it('jotai-devtools should create a dev store when calling createStore', () => { expect(isDevtoolsStore(createStore())).toBeTruthy(); }); - it('should bind the logger to the store created by jotai-devtools', () => { - const store = createStore(); - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - expect(isDevtoolsStore(store)).toBeTruthy(); - renderHook(() => { - useAtomsDevtools('devtools', { store }); - useAtomsLogger({ store }); - }); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(isDevtoolsStore(store)).toBeTruthy(); + it('should provide a logged store when wrapping a devtools store', () => { + const devtoolsStore = createStore(); + expect(isDevtoolsStore(devtoolsStore)).toBeTruthy(); + + let childStore: Store | undefined; + + function Child() { + useAtomsDevtools('devtools', { store: devtoolsStore }); + childStore = useStore(); + return null; + } + + render( + + + , + ); + + expect(isLoggedStore(childStore!)).toBeTruthy(); + expect(isDevtoolsStore(devtoolsStore)).toBeTruthy(); }); }); -describe('bindAtomsLoggerToStore', () => { - let store: ReturnType; +describe('jotai-devtools compatibility', () => { + let store: Store; + let loggedStore: Store; let consoleMock: { log: Mock; group: Mock; groupEnd: Mock; groupCollapsed: Mock; }; - let defaultOptions: AtomsLoggerOptions; + let defaultOptions: AtomLoggerOptions; beforeEach(() => { store = createStore(); @@ -87,12 +98,14 @@ describe('bindAtomsLoggerToStore', () => { groupCollapsed: vi.fn(), }; defaultOptions = { - logger: consoleMock, - groupTransactions: false, - groupEvents: false, - formattedOutput: false, - showTransactionElapsedTime: false, - autoAlignTransactions: false, + formatter: consoleFormatter({ + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + autoAlignTransactions: false, + }), }; }); @@ -100,21 +113,20 @@ describe('bindAtomsLoggerToStore', () => { vi.clearAllMocks(); }); - it('should bind the logger to the store created by jotai-devtools', () => { - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); + it('should create a logged store from a devtools store', () => { expect(isDevtoolsStore(store)).toBeTruthy(); - expect(bindAtomsLoggerToStore(store)).toBe(true); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); + loggedStore = createLoggedStore(store); + expect(isLoggedStore(loggedStore)).toBeTruthy(); expect(isDevtoolsStore(store)).toBeTruthy(); }); it('should log mounted and unmounted atoms with a devtool store', () => { expect(isDevtoolsStore(store)).toBeTruthy(); - bindAtomsLoggerToStore(store, defaultOptions); + loggedStore = createLoggedStore(store, defaultOptions); const testAtom = atom(42); - const unmount = store.sub(testAtom, vi.fn()); + const unmount = loggedStore.sub(testAtom, vi.fn()); vi.runAllTimers(); @@ -140,28 +152,28 @@ describe('bindAtomsLoggerToStore', () => { it('should log dependents when mounted with a devtool store', () => { expect(isDevtoolsStore(store)).toBeTruthy(); - bindAtomsLoggerToStore(store, defaultOptions); + loggedStore = createLoggedStore(store, defaultOptions); const aAtom = atom(1); const bAtom = atom((get) => get(aAtom) * 2); - store.sub(bAtom, vi.fn()); // store.sub mounts the atom - store.set(aAtom, 2); + loggedStore.sub(bAtom, vi.fn()); + loggedStore.set(aAtom, 2); vi.runAllTimers(); expect(consoleMock.log.mock.calls).toEqual([ [`transaction 1 - 4 events : subscribed to ${bAtom}`], - [`initialized value of ${aAtom} to 1`, { value: 1 }], + [`initialized value of ${aAtom} to 1`, { value: 1, dependents: [`${bAtom}`] }], [`initialized value of ${bAtom} to 2`, { value: 2, dependencies: [`${aAtom}`] }], - [`mounted ${aAtom}`, { value: 1 }], + [`mounted ${aAtom}`, { value: 1, dependents: [`${bAtom}`] }], [`mounted ${bAtom}`, { value: 2, dependencies: [`${aAtom}`] }], [`transaction 2 - 2 events : set value of ${aAtom} to 2`, { value: 2 }], [ `changed value of ${aAtom} from 1 to 2`, { - dependents: [`${bAtom}`], // OK + dependents: [`${bAtom}`], newValue: 2, oldValue: 1, }, @@ -169,7 +181,7 @@ describe('bindAtomsLoggerToStore', () => { [ `changed value of ${bAtom} from 2 to 4`, { - dependencies: [`${aAtom}`], // OK + dependencies: [`${aAtom}`], newValue: 4, oldValue: 2, }, diff --git a/tests/log-transactions-scheduler.test.ts b/tests/log-transactions-scheduler.test.ts index 68fa380..84a8309 100644 --- a/tests/log-transactions-scheduler.test.ts +++ b/tests/log-transactions-scheduler.test.ts @@ -1,23 +1,26 @@ import { createStore } from 'jotai'; -import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; - -import { bindAtomsLoggerToStore } from '../src/bind-atoms-logger-to-store.js'; -import { ATOMS_LOGGER_SYMBOL } from '../src/consts/atom-logger-symbol.js'; -import * as logTransactionModule from '../src/log-atom-event/log-transaction.js'; -import { createLogTransactionsScheduler } from '../src/log-transactions-scheduler.js'; import { - AtomsLoggerTransactionTypes, - type AtomsLoggerTransaction, - type StoreWithAtomsLogger, -} from '../src/types/atoms-logger.js'; - -function getFakeTransaction(transactionNumber: number): AtomsLoggerTransaction { - const transaction: AtomsLoggerTransaction = { - type: AtomsLoggerTransactionTypes.unknown, + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, + type MockInstance, +} from 'vitest'; + +import { createLoggedStore, getLoggedStoreOptions } from '../src/vanilla/create-logged-store.js'; +import { createLogTransactionsScheduler } from '../src/vanilla/log-transactions-scheduler.js'; +import type { AtomLoggerFormatter } from '../src/vanilla/types/formatter.js'; +import { AtomTransactionTypes, type AtomTransaction } from '../src/vanilla/types/transaction.js'; + +function getFakeTransaction(transactionNumber: number): AtomTransaction { + const transaction: AtomTransaction = { + type: AtomTransactionTypes.unknown, atom: `test-${transactionNumber}`, endTimestamp: -1, events: [], - eventsCount: 0, ownerStack: undefined, componentDisplayName: undefined, startTimestamp: -1, @@ -26,24 +29,25 @@ function getFakeTransaction(transactionNumber: number): AtomsLoggerTransaction { return transaction; } -function getFakeTransactions(count: number): AtomsLoggerTransaction[] { +function getFakeTransactions(count: number): AtomTransaction[] { return Array.from({ length: count }, (_, i) => getFakeTransaction(i)); } describe('logTransactionsScheduler', () => { let performanceNowSpy: MockInstance; let setTimeoutSpy: MockInstance; - let logTransactionSpy: MockInstance; + let formatterSpy: Mock; let requestIdleCallbackMockFn: MockInstance; const mockClearAllSpy = () => { setTimeoutSpy.mockClear(); - logTransactionSpy.mockClear(); + formatterSpy.mockClear(); performanceNowSpy.mockClear(); requestIdleCallbackMockFn.mockClear(); }; beforeEach(() => { + formatterSpy = vi.fn(); performanceNowSpy = vi.spyOn(performance, 'now').mockReturnValue(0); setTimeoutSpy = vi .spyOn(globalThis, 'setTimeout') @@ -53,7 +57,6 @@ describe('logTransactionsScheduler', () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return 1 as unknown as ReturnType; }); - logTransactionSpy = vi.spyOn(logTransactionModule, 'logTransaction'); requestIdleCallbackMockFn = vi.fn().mockImplementation((callback: IdleRequestCallback) => { callback({ didTimeout: false, timeRemaining: () => 50 }); return 1; @@ -67,60 +70,56 @@ describe('logTransactionsScheduler', () => { }); it('should schedule with requestIdleCallback if available', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { logger: { log: vi.fn() } }); - const scheduler = createLogTransactionsScheduler(store); + const store = createLoggedStore(createStore(), { formatter: formatterSpy }); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); - expect(logTransactionSpy).not.toHaveBeenCalled(); + expect(formatterSpy).not.toHaveBeenCalled(); expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); const transaction = getFakeTransaction(1); scheduler.add(transaction); expect(requestIdleCallbackMockFn).toHaveBeenCalledWith(expect.any(Function), { timeout: 250 }); - expect(logTransactionSpy).toHaveBeenCalledWith(transaction, store[ATOMS_LOGGER_SYMBOL]); + expect(formatterSpy).toHaveBeenCalledWith(transaction); expect(setTimeoutSpy).not.toHaveBeenCalled(); }); it('should fallback to setTimeout if requestIdleCallback is not available', () => { delete (globalThis as Partial).requestIdleCallback; - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { logger: { log: vi.fn() } }); - const scheduler = createLogTransactionsScheduler(store); + const store = createLoggedStore(createStore(), { formatter: formatterSpy }); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); - expect(logTransactionSpy).not.toHaveBeenCalled(); + expect(formatterSpy).not.toHaveBeenCalled(); expect(setTimeoutSpy).not.toHaveBeenCalled(); const transaction = getFakeTransaction(1); scheduler.add(transaction); - expect(logTransactionSpy).toHaveBeenCalledWith(transaction, store[ATOMS_LOGGER_SYMBOL]); + expect(formatterSpy).toHaveBeenCalledWith(transaction); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0); }); it('should process all transactions when maxProcessingTimeMs is 0 (disabled)', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, maxProcessingTimeMs: 0, }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.queue = getFakeTransactions(50); scheduler.process(); - expect(logTransactionSpy).toHaveBeenCalledTimes(50); // All processed + expect(formatterSpy).toHaveBeenCalledTimes(50); // All processed }); it('should not call performance.now when maxProcessingTimeMs is disabled', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, maxProcessingTimeMs: 0, }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.queue = getFakeTransactions(20); scheduler.process(); @@ -128,25 +127,24 @@ describe('logTransactionsScheduler', () => { }); it('should call performance.now for start time on each process cycle', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, maxProcessingTimeMs: 10, }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.queue = getFakeTransactions(5); scheduler.process(); expect(scheduler.isProcessing).toBe(false); - expect(logTransactionSpy).toHaveBeenCalledTimes(5); // All processed + expect(formatterSpy).toHaveBeenCalledTimes(5); // All processed expect(performanceNowSpy).toHaveBeenCalledTimes(1); // Only for start time expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); scheduler.queue = getFakeTransactions(5); scheduler.process(); expect(scheduler.isProcessing).toBe(false); - expect(logTransactionSpy).toHaveBeenCalledTimes(10); // All processed + expect(formatterSpy).toHaveBeenCalledTimes(10); // All processed expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Only for start time expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(2); }); @@ -166,54 +164,51 @@ describe('logTransactionsScheduler', () => { return 1; }); - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, maxProcessingTimeMs: 10, // Allow processing to continue }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.queue = getFakeTransactions(12); scheduler.process(); // Waiting for requestIdleCallback expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); - expect(logTransactionSpy).not.toHaveBeenCalled(); + expect(formatterSpy).not.toHaveBeenCalled(); expect(performanceNowSpy).not.toHaveBeenCalled(); mockClearAllSpy(); requestIdleCallbacks.shift()!(); // Invoke the 1st scheduled callback expect(requestIdleCallbackMockFn).toHaveBeenCalledTimes(1); // Called again due to time limit - expect(logTransactionSpy).toHaveBeenCalledTimes(10); // Processed until checkTimeInterval (10) + expect(formatterSpy).toHaveBeenCalledTimes(10); // Processed until checkTimeInterval (10) expect(performanceNowSpy).toHaveBeenCalledTimes(2); // Start + first check mockClearAllSpy(); requestIdleCallbacks.shift()!(); // Invoke the 2nd scheduled callback expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); // Finished processing - expect(logTransactionSpy).toHaveBeenCalledTimes(2); // Processed remaining 2 + expect(formatterSpy).toHaveBeenCalledTimes(2); // Processed remaining 2 expect(performanceNowSpy).toHaveBeenCalledTimes(1); // Start only (not reached checkTimeInterval) }); it('should handle empty queue gracefully', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { logger: { log: vi.fn() } }); - const scheduler = createLogTransactionsScheduler(store); + const store = createLoggedStore(createStore(), { formatter: formatterSpy }); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.process(); // Process empty queue expect(scheduler.isProcessing).toBe(false); expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); expect(setTimeoutSpy).not.toHaveBeenCalled(); - expect(logTransactionSpy).not.toHaveBeenCalled(); + expect(formatterSpy).not.toHaveBeenCalled(); expect(performanceNowSpy).not.toHaveBeenCalled(); }); it('should prevent concurrent processing', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { logger: { log: vi.fn() } }); - const scheduler = createLogTransactionsScheduler(store); + const store = createLoggedStore(createStore(), { formatter: formatterSpy }); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.isProcessing = true; // Simulate ongoing processing scheduler.queue = getFakeTransactions(5); @@ -222,14 +217,13 @@ describe('logTransactionsScheduler', () => { expect(scheduler.isProcessing).toBe(true); // Still "processing" expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); expect(setTimeoutSpy).not.toHaveBeenCalled(); - expect(logTransactionSpy).not.toHaveBeenCalled(); + expect(formatterSpy).not.toHaveBeenCalled(); expect(performanceNowSpy).not.toHaveBeenCalled(); }); it('should handle null transactions in queue', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { logger: { log: vi.fn() } }); - const scheduler = createLogTransactionsScheduler(store); + const store = createLoggedStore(createStore(), { formatter: formatterSpy }); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); // Manually add undefined to queue (edge case) // @ts-expect-error: Testing edge case with invalid data @@ -237,16 +231,15 @@ describe('logTransactionsScheduler', () => { scheduler.queue.push(getFakeTransaction(1)); scheduler.process(); - expect(logTransactionSpy).toHaveBeenCalledTimes(1); // Only valid transaction processed + expect(formatterSpy).toHaveBeenCalledTimes(1); // Only valid transaction processed }); it('should use requestIdleCallback timeout from store configuration', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, requestIdleCallbackTimeoutMs: 500, // Custom timeout }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.add(getFakeTransaction(1)); @@ -254,19 +247,18 @@ describe('logTransactionsScheduler', () => { }); it('should execute immediately when requestIdleCallbackTimeoutMs is -1', () => { - const store = createStore() as StoreWithAtomsLogger; - bindAtomsLoggerToStore(store, { - logger: { log: vi.fn() }, + const store = createLoggedStore(createStore(), { + formatter: formatterSpy, requestIdleCallbackTimeoutMs: -1, // Immediate execution maxProcessingTimeMs: -1, // Disable time checks }); - const scheduler = createLogTransactionsScheduler(store); + const scheduler = createLogTransactionsScheduler(getLoggedStoreOptions(store)!); scheduler.add(getFakeTransaction(1)); expect(requestIdleCallbackMockFn).not.toHaveBeenCalled(); expect(setTimeoutSpy).not.toHaveBeenCalled(); - expect(logTransactionSpy).toHaveBeenCalledTimes(1); + expect(formatterSpy).toHaveBeenCalledTimes(1); expect(performanceNowSpy).not.toHaveBeenCalled(); }); }); diff --git a/tests/parse-owner-stack.test.ts b/tests/parse-owner-stack.test.ts index 8dee88f..4b67854 100644 --- a/tests/parse-owner-stack.test.ts +++ b/tests/parse-owner-stack.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { parseOwnerStack } from '../src/utils/parse-owner-stack.js'; +import { parseOwnerStack } from '../src/formatters/console/utils/parse-owner-stack.js'; describe('parseOwnerStack', () => { it('should return undefined for null input', () => { diff --git a/tests/stack-traces.test.tsx b/tests/react-stack-traces.test.tsx similarity index 93% rename from tests/stack-traces.test.tsx rename to tests/react-stack-traces.test.tsx index a6f74a7..0c458f2 100644 --- a/tests/stack-traces.test.tsx +++ b/tests/react-stack-traces.test.tsx @@ -13,8 +13,9 @@ import { vi, } from 'vitest'; -import type { AtomsLoggerOptions } from '../src/types/atoms-logger.js'; -import { useAtomsLogger } from '../src/use-atoms-logger.js'; +import { consoleFormatter } from '../src/formatters/console/index.js'; +import { AtomLoggerProvider } from '../src/react/atom-logger-provider.js'; +import type { AtomLoggerOptions } from '../src/vanilla/types/options.js'; let mockDate: MockInstance; @@ -46,14 +47,14 @@ function getReact19ComponentDisplayName(): string | undefined { return component?.displayName ?? component?.name; } -describe('stack traces', () => { +describe('react stack traces', () => { let consoleMock: { log: Mock; group: Mock; groupEnd: Mock; groupCollapsed: Mock; }; - let defaultOptions: AtomsLoggerOptions; + let defaultOptions: AtomLoggerOptions; beforeEach(() => { consoleMock = { @@ -63,13 +64,15 @@ describe('stack traces', () => { groupCollapsed: vi.fn(), }; defaultOptions = { - logger: consoleMock, - groupTransactions: false, - groupEvents: false, - formattedOutput: false, - showTransactionElapsedTime: false, + formatter: consoleFormatter({ + logger: consoleMock, + groupTransactions: false, + groupEvents: false, + formattedOutput: false, + showTransactionElapsedTime: false, + autoAlignTransactions: false, + }), shouldShowPrivateAtoms: false, - autoAlignTransactions: false, getOwnerStack: captureOwnerStack, getComponentDisplayName: getReact19ComponentDisplayName, }; @@ -91,17 +94,13 @@ describe('stack traces', () => { ); incrementAtom.debugLabel = 'incrementAtom'; - function AtomsLogger(options?: AtomsLoggerOptions) { - useAtomsLogger({ ...defaultOptions, ...options }); - return null; - } - - function renderWithLogger(children: React.ReactNode, options?: AtomsLoggerOptions) { + function renderWithLogger(children: React.ReactNode, options?: AtomLoggerOptions) { const store = createStore(); render( - - {children} + + {children} + , ); } diff --git a/tests/stringify-value.test.ts b/tests/stringify-value.test.ts index d6e06ed..a5f2c22 100644 --- a/tests/stringify-value.test.ts +++ b/tests/stringify-value.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { stringifyValue } from '../src/utils/stringify-value.js'; +import { stringifyValue } from '../src/formatters/console/utils/stringify-value.js'; describe('stringifyValue', () => { const defaultOptions = { diff --git a/tests/use-atoms-logger.test.ts b/tests/use-atoms-logger.test.ts deleted file mode 100644 index f89e2c6..0000000 --- a/tests/use-atoms-logger.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// @vitest-environment jsdom -import { renderHook } from '@testing-library/react'; -import { createStore, getDefaultStore } from 'jotai'; -import { useEffect, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { isAtomsLoggerBoundToStore } from '../src/bind-atoms-logger-to-store.js'; -import * as bindAtomsLoggerToStoreModule from '../src/bind-atoms-logger-to-store.js'; -import { ATOMS_LOGGER_SYMBOL } from '../src/consts/atom-logger-symbol.js'; -import { bindAtomsLoggerToStore, useAtomsLogger } from '../src/index.js'; -import { - type AtomsLoggerOptions, - type AtomsLoggerOptionsInState, - type Store, - type StoreWithAtomsLogger, -} from '../src/types/atoms-logger.js'; - -describe('useAtomsLogger', () => { - it('should bind logger to store', () => { - const store = createStore(); - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - renderHook(() => { - useAtomsLogger({ store }); - }); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - }); - - it('should bind logger to default store if store is not provided', () => { - expect(isAtomsLoggerBoundToStore(getDefaultStore())).toBeFalsy(); - renderHook(() => { - useAtomsLogger(); - }); - expect(isAtomsLoggerBoundToStore(getDefaultStore())).toBeTruthy(); - }); - - it('should not bind logger to store when disabled', () => { - const store = createStore(); - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - renderHook(() => { - useAtomsLogger({ store, enabled: false }); - }); - expect(isAtomsLoggerBoundToStore(store)).toBeFalsy(); - }); - - it('should not bind logger to store when already bound', () => { - const store = createStore(); - bindAtomsLoggerToStore(store); - - const bindAtomsLoggerToStoreSpy = vi.spyOn( - bindAtomsLoggerToStoreModule, - 'bindAtomsLoggerToStore', - ); - - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(bindAtomsLoggerToStoreSpy).not.toHaveBeenCalled(); - renderHook(() => { - useAtomsLogger({ store }); - }); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect(bindAtomsLoggerToStoreSpy).not.toHaveBeenCalled(); - }); - - it('should update logger options when they change', () => { - const store = createStore(); - renderHook(() => { - const [options, setOptions] = useState({ - groupTransactions: false, - groupEvents: false, - shouldShowPrivateAtoms: false, - stringifyLimit: 50, - }); - useAtomsLogger({ store, ...options }); - useEffect(() => { - setOptions({ - groupTransactions: true, - groupEvents: true, - shouldShowPrivateAtoms: true, - stringifyLimit: 100, - }); - }, []); - }); - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect((store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]).toEqual( - expect.objectContaining({ - groupTransactions: true, - groupEvents: true, - shouldShowPrivateAtoms: true, - stringifyLimit: 100, - }), - ); - }); - - it('should disable previous store logger when store changes', () => { - // const store = createStore(); - const stores: Store[] = []; - renderHook(() => { - const [store, setStore] = useState(() => createStore()); - stores.push(store); - useAtomsLogger({ store }); - useEffect(() => { - setStore(createStore()); - }, []); - }); - - expect(stores).toHaveLength(2); - expect(stores[0]).not.toBe(stores[1]); - expect(isAtomsLoggerBoundToStore(stores[0]!)).toBeTruthy(); - expect(isAtomsLoggerBoundToStore(stores[1]!)).toBeTruthy(); - - expect((stores[0] as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL].enabled).toBe(false); - expect((stores[1] as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL].enabled).toBe(true); - }); - - it('should use default options when none provided', () => { - const store = createStore(); - renderHook(() => { - useAtomsLogger({ store }); - }); - const expectedDefaultOptions: AtomsLoggerOptionsInState = { - enabled: true, - domain: undefined, - shouldShowPrivateAtoms: false, - shouldShowAtom: undefined, - logger: console, - groupTransactions: true, - groupEvents: false, - indentSpaces: 0, - indentSpacesDepth1: '', - indentSpacesDepth2: '', - formattedOutput: true, - colorScheme: 'default', - stringifyLimit: 50, - stringifyValues: true, - stringify: undefined, - showTransactionNumber: true, - showTransactionEventsCount: true, - showTransactionLocaleTime: false, - showTransactionElapsedTime: true, - autoAlignTransactions: true, - collapseTransactions: true, - collapseEvents: false, - ownerStackLimit: 2, - transactionDebounceMs: 250, - requestIdleCallbackTimeoutMs: 250, - maxProcessingTimeMs: 16, - }; - expect(isAtomsLoggerBoundToStore(store)).toBeTruthy(); - expect((store as StoreWithAtomsLogger)[ATOMS_LOGGER_SYMBOL]).toEqual( - expect.objectContaining(expectedDefaultOptions), - ); - }); -}); diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 5dd2cdf..824510a 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -4,7 +4,8 @@ "outDir": "dist", "rootDir": "src", "sourceMap": true, - "declaration": true + "declaration": true, + "jsx": "react-jsx" }, "include": ["src/**/*"] }