diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 357d41529..88adccda3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,17 @@ on: #release: # types: [published] workflow_dispatch: + inputs: + tag: + description: 'NPM dist-tag (latest, alpha, beta, next)' + required: true + default: 'latest' + type: choice + options: + - latest + - next + - alpha + - beta permissions: {} @@ -29,4 +40,4 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm test - - run: npm publish --access public --provenance + - run: npm publish --access public --provenance --tag ${{ inputs.tag }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 622a11845..4b524c33e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -213,8 +213,12 @@ jobs: run: pnpm install # Note: We currently expect "FalseCJS" failures for Node16 + `moduleResolution: "node16", + # `--profile node16` drops the node10 resolution check. The subpath + # entry points (`alternate-renderers`, `signals`) only resolve through + # the `exports` map, which node10 does not read, and we do not support + # Node 10 style resolution for them. - name: Run are-the-types-wrong - run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs --exclude-entrypoints alternate-renderers + run: pnpm exec attw ./package.tgz --format table --ignore-rules false-cjs --profile node16 test-published-artifact: name: Test Published Artifact ${{ matrix.example }} @@ -330,7 +334,7 @@ jobs: fail-fast: false matrix: node: ['24.x'] - example: ['rr-rsc-context'] + example: ['rr-rsc-context', 'rr-tree-shaking'] defaults: run: working-directory: ./examples/publish-ci/${{ matrix.example }} diff --git a/.oxlintrc.json b/.oxlintrc.json index 9799dcbc5..7024e0c1b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -47,6 +47,16 @@ "rules": { "no-unused-expressions": "off" } + }, + { + "files": [ + "test/signals/diff.spec.ts", + "test/signals/identityArrayTracking.spec.ts", + "test/signals/trackingProxy.spec.ts" + ], + "rules": { + "no-unused-expressions": "off" + } } ] } diff --git a/docs/api/Provider.md b/docs/api/Provider.md index 28ae2cc39..bd1c93450 100644 --- a/docs/api/Provider.md +++ b/docs/api/Provider.md @@ -18,6 +18,8 @@ Since any React component in a React Redux app can be connected to the store, mo The [Hooks](./hooks.md) and [`connect`](./connect.md) APIs can then access the provided store instance via React's Context mechanism. +If you want to use the [`useSignalSelector`](./useSignalSelector.md) hook, use the drop-in [``](./SignalProvider.md) replacement instead. + ### Props ```ts diff --git a/docs/api/SignalProvider.md b/docs/api/SignalProvider.md new file mode 100644 index 000000000..84ca15694 --- /dev/null +++ b/docs/api/SignalProvider.md @@ -0,0 +1,82 @@ +--- +id: SignalProvider +title: SignalProvider +sidebar_label: SignalProvider +hide_title: true +description: 'API > SignalProvider: a drop-in replacement for Provider that enables signal-based selector tracking' +--- + +  + +# `SignalProvider` + +_added in v9.4.0_ + +`` is a drop-in replacement for [``](./Provider.md) that enables fine-grained, signal-based state tracking for the [`useSignalSelector`](./useSignalSelector.md) hook. + +Like ``, it makes the Redux `store` available to any nested components. In addition, it maintains an internal graph of "path signals" that represent individual fields within the Redux state. On every dispatch, it diffs the previous and next state, and updates only the signals for the fields that actually changed. `useSignalSelector` uses that graph to re-run selectors only when a field they actually read has changed. + +```jsx +import { SignalProvider } from 'react-redux' + +const store = createStore(rootReducer) + +const root = ReactDOM.createRoot(document.getElementById('root')) +root.render( + + + , +) +``` + +:::tip + +All of the standard React Redux APIs - [`useSelector`](./useSelector.md), [`useDispatch`](./useDispatch.md), [`useStore`](./useStore.md), and [`connect`](./connect.md) - work unchanged inside a ``. They receive the plain Redux state and behave exactly as they do under ``. Only components that use `useSignalSelector` opt in to the fine-grained tracking behavior. + +::: + +## API Reference + +### Props + +`SignalProvider` accepts the same props as [``](./Provider.md): + +| Name | Description | +| :----------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------- | +| `store` | The single Redux store in your application. | +| `children` | The top-level React elements in your component tree. | +| `context?` | An optional custom context instance, as with ``. | +| `serverState?` | An optional server state snapshot, used during initial hydration to keep the UI output consistent with the server-generated HTML. | +| `stabilityCheck?` | Global default for the `useSelector`/`useSignalSelector` [selector stability check](./useSelector.md#selector-result-stability) (development only). | +| `identityFunctionCheck?` | Global default for the [identity function check](./useSelector.md#identity-function-state--state-check) (development only). | + +## Usage Guide + +### Differences from `Provider` + +`` behaves like ``, with these differences: + +#### The state must be a plain object + +The root of your Redux state must be a plain JavaScript object, because the state diffing and path tracking operate on its keys. This is reflected in the types: `SignalProvider`'s state generic is constrained to `S extends object`, where `Provider` accepts any state type. + +Standard Redux usage with `combineReducers` or Redux Toolkit's `configureStore` always produces a plain object root, so this only matters if your root reducer returns a primitive, a class instance, or another non-plain value. Those setups should keep using `` and `useSelector`. + +#### Every dispatch runs a state diff + +Before notifying subscribers, `` compares the previous and next state and updates the changed path signals. This is a structural walk of the changed portions of the state, and adds some work to each dispatch in exchange for skipping selector re-runs in components. See the [`useSignalSelector` performance notes](./useSignalSelector.md#performance-characteristics) for the tradeoffs. + +#### The signal graph is keyed to the store + +Each `` owns one signal registry per store instance. Swapping the `store` prop starts over with a fresh registry, the same way `` resets its subscriptions. Multiple ``s with different stores (via custom contexts) keep fully independent signal graphs. + +### When should I use `SignalProvider`? + +Use `` if you want to adopt [`useSignalSelector`](./useSignalSelector.md) anywhere in your app. Since all the other React Redux APIs work unchanged, it is safe to swap in at the root and migrate components incrementally. + +If your app does not use `useSignalSelector`, there is no benefit to `` - the per-dispatch diff adds cost without anything consuming its results. Use the standard ``. + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md): the hook that consumes the signal graph +- [`Provider`](./Provider.md): the standard store provider diff --git a/docs/api/batch.md b/docs/api/batch.md index 9b0beb0b8..77b407288 100644 --- a/docs/api/batch.md +++ b/docs/api/batch.md @@ -16,10 +16,12 @@ batch((fn: () => void)) _added in v7.0.0_ -:::info +:::caution Deprecated **If you're using React 18, you do not need to use the `batch` API**. React 18 automatically batches _all_ state updates, no matter where they're queued. +`batch` is now a no-op that immediately runs the callback, and **will be removed in the next major version of React Redux**. + ::: React's `unstable_batchedUpdates()` API allows any React updates in an event loop tick to be batched together into a single render pass. React already uses this internally for its own event handler callbacks. This API is actually part of the renderer packages like ReactDOM and React Native, not the React core itself. diff --git a/docs/api/hooks.md b/docs/api/hooks.md index fdbcc2274..92c65b0d6 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -1,9 +1,9 @@ --- id: hooks title: Hooks -sidebar_label: Hooks +sidebar_label: Hooks Overview hide_title: true -description: 'API > Hooks: the `useSelector` and `useDispatch` hooks`' +description: 'API > Hooks: an overview of the React Redux hooks APIs' ---   @@ -24,6 +24,8 @@ The existing `connect` API still works and will continue to be supported, but th These hooks were first added in v7.1.0. +Each hook has its own API reference page. This page provides an overview of the available hooks and shared usage information. + ## Using Hooks in a React Redux App As with `connect()`, you should start by wrapping your entire application in a `` component to make the store available throughout the component tree: @@ -42,399 +44,33 @@ root.render( From there, you may import any of the listed React Redux hooks APIs and use them within your function components. -## `useSelector()` +## The Hooks APIs -```ts -type RootState = ReturnType -type SelectorFn = (state: RootState) => Selected -type EqualityFn = (a: any, b: any) => boolean -export type DevModeCheckFrequency = 'never' | 'once' | 'always' +### `useSelector()` -interface UseSelectorOptions { - equalityFn?: EqualityFn - devModeChecks?: { - stabilityCheck?: DevModeCheckFrequency - identityFunctionCheck?: DevModeCheckFrequency - } -} +Reads a value from the Redux store state using a selector function, and subscribes the component so it re-renders when that value changes. This is the primary hook for reading Redux state in components. -const result: Selected = useSelector( - selector: SelectorFn, - options?: EqualityFn | UseSelectorOptions -) -``` +**[Full `useSelector` API reference →](./useSelector.md)** -Allows you to extract data from the Redux store state for use in this component, using a selector function. +### `useDispatch()` -:::info +Returns the store's `dispatch` function, so components can dispatch actions in event handlers and effects. -The selector function should be [pure](https://en.wikipedia.org/wiki/Pure_function) since it is potentially executed multiple times and at arbitrary points in time. - -See [Using Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) in the Redux docs for more details on writing and using selector functions. - -::: +**[Full `useDispatch` API reference →](./useDispatch.md)** -The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside `state`, or deriving new values. The return value of the selector will be used as the return value of the `useSelector()` hook. +### `useStore()` -The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. +Returns the Redux store instance itself. Rarely needed - prefer `useSelector` for reading state. -When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). - -The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. - -You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. - -:::info - -There are potential edge cases with using props in selectors that may cause issues. See the [Usage Warnings](#usage-warnings) section of this page for further details. - -::: +**[Full `useStore` API reference →](./useStore.md)** -### Equality Comparisons and Updates - -When the function component renders, the provided selector function will be called and its result will be returned -from the `useSelector()` hook. (A cached result may be returned by the hook without re-running the selector if it's the same function reference as on a previous render of the component.) - -However, when an action is dispatched to the Redux store, `useSelector()` only forces a re-render if the selector result -appears to be different than the last result. The default comparison is a strict `===` reference -comparison. This is different than `connect()`, which uses shallow equality checks on the results of `mapState` calls -to determine if re-rendering is needed. This has several implications on how you should use `useSelector()`. - -With `mapState`, all individual fields were returned in a combined object. It didn't matter if the return object was -a new reference or not - `connect()` just compared the individual fields. With `useSelector()`, returning a new object -every time will _always_ force a re-render by default. If you want to retrieve multiple values from the store, you can: - -- Call `useSelector()` multiple times, with each call returning a single field value -- Use Reselect or a similar library to create a memoized selector that returns multiple values in one object, but - only returns a new object when one of the values has changed. -- Use the `shallowEqual` function from React-Redux as the `equalityFn` argument to `useSelector()`, like: - -```js -import { shallowEqual, useSelector } from 'react-redux' - -// Pass it as the second argument directly -const selectedData = useSelector(selectorReturningObject, shallowEqual) - -// or pass it as the `equalityFn` field in the options argument -const selectedData = useSelector(selectorReturningObject, { - equalityFn: shallowEqual, -}) -``` - -- Use a custom equality function as the `equalityFn` argument to `useSelector()`, like: - -```js -import { useSelector } from 'react-redux' - -// equality function -const customEqual = (oldValue, newValue) => oldValue === newValue - -// later -const selectedData = useSelector(selectorReturningObject, customEqual) -``` - -The optional comparison function also enables using something like Lodash's `_.isEqual()` or Immutable.js's comparison capabilities. - -### `useSelector` Examples - -Basic usage: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' - -export const CounterComponent = () => { - const counter = useSelector((state) => state.counter) - return
{counter}
-} -``` - -Using props via closure to determine what to extract: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' - -export const TodoListItem = (props) => { - const todo = useSelector((state) => state.todos[props.id]) - return
{todo.text}
-} -``` - -#### Using memoizing selectors - -When using `useSelector` with an inline selector as shown above, a new instance of the selector is created whenever the component is rendered. This works as long as the selector does not maintain any state. However, memoizing selectors (e.g. created via `createSelector` from `reselect`) do have internal state, and therefore care must be taken when using them. Below you can find typical usage scenarios for memoizing selectors. - -When the selector does only depend on the state, simply ensure that it is declared outside of the component so that the same selector instance is used for each render: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' -import { createSelector } from 'reselect' - -const selectNumCompletedTodos = createSelector( - (state) => state.todos, - (todos) => todos.filter((todo) => todo.completed).length, -) - -export const CompletedTodosCounter = () => { - const numCompletedTodos = useSelector(selectNumCompletedTodos) - return
{numCompletedTodos}
-} - -export const App = () => { - return ( - <> - Number of completed todos: - - - ) -} -``` - -The same is true if the selector depends on the component's props, but will only ever be used in a single instance of a single component: - -```jsx -import React from 'react' -import { useSelector } from 'react-redux' -import { createSelector } from 'reselect' - -const selectCompletedTodosCount = createSelector( - (state) => state.todos, - (_, completed) => completed, - (todos, completed) => - todos.filter((todo) => todo.completed === completed).length, -) - -export const CompletedTodosCount = ({ completed }) => { - const matchingCount = useSelector((state) => - selectCompletedTodosCount(state, completed), - ) - - return
{matchingCount}
-} - -export const App = () => { - return ( - <> - Number of done todos: - - - ) -} -``` - -However, when the selector is used in multiple component instances and depends on the component's props, you need to ensure that selector's memoization behavior is properly configured (see [here](https://reselect.js.org/faq/#can-i-share-a-selector-across-multiple-component-instances) for details). - -### Development mode checks - -`useSelector` runs some extra checks in development mode to watch for unexpected behavior. These checks do not run in production builds. - -:::info - -These checks were first added in v8.1.0 - -::: - -#### Selector result stability - -In development, the provided selector function is run an extra time with the same parameter during the first call to `useSelector`, and warns in the console if the selector returns a different result (based on the `equalityFn` provided). - -This is important, as **a selector that returns a different result reference when called again with the same inputs will cause unnecessary rerenders**. - -```ts -// this selector will return a new object reference whenever called, -// which causes the component to rerender after *every* action is dispatched -const { count, user } = useSelector((state) => ({ - count: state.count, - user: state.user, -})) -``` - -If a selector result is suitably stable (or the selector is memoized), it will not return a different result and no warning will be logged. - -By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. - -```tsx title="Global setting via context" - - {children} - -``` - -```tsx title="Individual hook setting" -function Component() { - const count = useSelector(selectCount, { - devModeChecks: { stabilityCheck: 'never' }, - }) - // run once (default) - const user = useSelector(selectUser, { - devModeChecks: { stabilityCheck: 'once' }, - }) - // ... -} -``` - -#### Identity Function (`state => state`) Check - -:::danger Breaking Change! - -This was previously referred to as `noopCheck`. - -::: +### `useSignalSelector()` -In development, a check is conducted on the result returned by the selector. It warns in the console if the result is the same as the parameter passed in, i.e. the root state. +_added in v9.4.0_ -**A `useSelector` call returning the entire root state is almost always a mistake**, as it means the component will rerender whenever _anything_ in state changes. Selectors should be as granular as possible, like `state => state.some.nested.field`. +A fine-grained variant of `useSelector` that automatically tracks which parts of the state the selector reads, and only re-runs the selector when those parts change. Requires wrapping the app in [``](./SignalProvider.md) instead of ``. -```ts no-transpile -// BAD: this selector returns the entire state, meaning that the component will rerender unnecessarily -const { count, user } = useSelector((state) => state) - -// GOOD: instead, select only the state you need, calling useSelector as many times as needed -const count = useSelector((state) => state.count.value) -const user = useSelector((state) => state.auth.currentUser) -``` - -By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. - -```tsx title="Global setting via context" - - {children} - -``` - -```tsx title="Individual hook setting" -function Component() { - const count = useSelector(selectCount, { - devModeChecks: { identityFunctionCheck: 'never' }, - }) - // run once (default) - const user = useSelector(selectUser, { - devModeChecks: { identityFunctionCheck: 'once' }, - }) - // ... -} -``` - -### Comparisons with `connect` - -There are some differences between the selectors passed to `useSelector()` and a `mapState` function: - -- The selector may return any value as a result, not just an object. -- The selector normally _should_ return just a single value, and not an object. If you do return an object or an array, be sure to use a memoized selector to avoid unnecessary re-renders. -- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples above) or by using a curried selector. -- You can use the `equalityFn` option to customize the comparison behavior - -## `useDispatch()` - -```ts -import type { Dispatch } from 'redux' -const dispatch: Dispatch = useDispatch() -``` - -This hook returns a reference to the `dispatch` function from the Redux store. You may use it to dispatch actions as needed. - -#### Examples - -```jsx -import React from 'react' -import { useDispatch } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const dispatch = useDispatch() - - return ( -
- {value} - -
- ) -} -``` - -When passing a callback using `dispatch` to a child component, you may sometimes want to memoize it with [`useCallback`](https://react.dev/reference/react/useCallback). _If_ the child component is trying to optimize render behavior using `React.memo()` or similar, this avoids unnecessary rendering of child components due to the changed callback reference. - -```jsx -import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const dispatch = useDispatch() - const incrementCounter = useCallback( - () => dispatch({ type: 'increment-counter' }), - [dispatch], - ) - - return ( -
- {value} - -
- ) -} - -export const MyIncrementButton = React.memo(({ onIncrement }) => ( - -)) -``` - -:::info - -The `dispatch` function reference will be stable as long as the same store instance is being passed to the ``. -Normally, that store instance never changes in an application. - -However, the React hooks lint rules do not know that `dispatch` should be stable, and will warn that the `dispatch` variable -should be added to dependency arrays for `useEffect` and `useCallback`. The simplest solution is to do just that: - -```js -export const Todos = () => { - const dispatch = useDispatch() - - useEffect(() => { - dispatch(fetchTodos()) - // highlight-start - // Safe to add dispatch to the dependencies array - }, [dispatch]) - // highlight-end -} -``` - -::: - -## `useStore()` - -```ts -import type { Store } from 'redux' -const store: Store = useStore() -``` - -This hook returns a reference to the same Redux store that was passed in to the `` component. - -This hook should probably not be used frequently. Prefer `useSelector()` as your primary choice. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers. - -#### Examples - -```js -import React from 'react' -import { useStore } from 'react-redux' - -export const ExampleComponent = ({ value }) => { - const store = useStore() - - const onClick = () => { - // Not _recommended_, but safe - // This avoids subscribing to the state via `useSelector` - // Prefer moving this logic into a thunk instead - const numTodos = store.getState().todos.length - } - - // EXAMPLE ONLY! Do not do this in a real app. - // The component will not automatically update if the store state changes - return
{store.getState().todos.length}
-} -``` +**[Full `useSignalSelector` API reference →](./useSignalSelector.md)** ## Custom context @@ -469,75 +105,13 @@ export function MyProvider({ children }) { } ``` -## Usage Warnings - -### Stale Props and "Zombie Children" - -:::info - -The React-Redux hooks API has been production-ready since we released it in v7.1.0, and **we recommend using the hooks API as the default approach in your components**. However, there are a couple of edge cases that can occur, and **we're documenting those so that you can be aware of them**. - -In practice, these are a rare concern - we've received far more comments about these being in the docs than actual reports of these being a real problem in an app. - -::: - -One of the most difficult aspects of React Redux's implementation is ensuring that if your `mapStateToProps` function is defined as `(state, ownProps)`, it will be called with the "latest" props every time. Up through version 4, there were recurring bugs reported involving edge case situations, such as errors thrown from a `mapState` function for a list item whose data had just been deleted. - -Starting with version 5, React Redux has attempted to guarantee that consistency with `ownProps`. In version 7, that is implemented using a custom `Subscription` class internally in `connect()`, which forms a nested hierarchy. This ensures that connected components lower in the tree will only receive store update notifications once the nearest connected ancestor has been updated. However, this relies on each `connect()` instance overriding part of the internal React context, supplying its own unique `Subscription` instance to form that nesting, and rendering the `` with that new context value. - -With hooks, there is no way to render a context provider, which means there's also no nested hierarchy of subscriptions. Because of this, the "stale props" and "zombie child" issues may potentially re-occur in an app that relies on using hooks instead of `connect()`. - -Specifically, "stale props" means any case where: - -- a selector function relies on this component's props to extract data -- a parent component _would_ re-render and pass down new props as a result of an action -- but this component's selector function executes before this component has had a chance to re-render with those new props - -Depending on what props were used and what the current store state is, this _may_ result in incorrect data being returned from the selector, or even an error being thrown. - -"Zombie child" refers specifically to the case where: - -- Multiple nested connected components are mounted in a first pass, causing a child component to subscribe to the store before its parent -- An action is dispatched that deletes data from the store, such as a todo item -- The parent component _would_ stop rendering that child as a result -- However, because the child subscribed first, its subscription runs before the parent stops rendering it. When it reads a value from the store based on props, that data no longer exists, and if the extraction logic is not careful, this may result in an error being thrown. - -`useSelector()` tries to deal with this by catching all errors that are thrown when the selector is executed due to a store update (but not when it is executed during rendering). When an error occurs, the component will be forced to render, at which point the selector is executed again. This works as long as the selector is a pure function and you do not depend on the selector throwing errors. - -If you prefer to deal with this issue yourself, here are some possible options for avoiding these problems altogether with `useSelector()`: - -- Don't rely on props in your selector function for extracting data -- In cases where you do rely on props in your selector function _and_ those props may change over time, _or_ the data you're extracting may be based on items that can be deleted, try writing the selector functions defensively. Don't just reach straight into `state.todos[props.id].name` - read `state.todos[props.id]` first, and verify that it exists before trying to read `todo.name`. -- Because `connect` adds the necessary `Subscription` to the context provider and delays evaluating child subscriptions until the connected component has re-rendered, putting a connected component in the component tree just above the component using `useSelector` will prevent these issues as long as the connected component gets re-rendered due to the same store update as the hooks component. - -:::info +See [Accessing the Store](../using-react-redux/accessing-store.md) for more details on context usage, multiple stores, and accessing the store directly. -For a longer description of these scenarios, see: - -- ["Stale props and zombie children in Redux" by Kai Hao](https://kaihao.dev/posts/Stale-props-and-zombie-children-in-Redux) -- [A chat log that describes the problems in more detail](https://gist.github.com/markerikson/faac6ae4aca7b82a058e13216a7888ec) -- [issue #1179](https://github.com/reduxjs/react-redux/issues/1179) - -::: - -### Performance - -As mentioned earlier, by default `useSelector()` will do a reference equality comparison of the selected value when running the selector function after an action is dispatched, and will only cause the component to re-render if the selected value changed. However, unlike `connect()`, `useSelector()` does not prevent the component from re-rendering due to its parent re-rendering, even if the component's props did not change. - -If further performance optimizations are necessary, you may consider wrapping your function component in `React.memo()`: +## Usage Warnings -```jsx -const CounterComponent = ({ name }) => { - const counter = useSelector((state) => state.counter) - return ( -
- {name}: {counter} -
- ) -} +Because the hooks subscribe to the store individually rather than forming the nested subscription hierarchy that `connect` uses, there are a couple of edge cases around "stale props" and "zombie children" that can occur when selectors rely on props. In practice these are a rare concern - `useSelector` handles them automatically by catching selector errors during store updates and re-running the selector on the next render. -export const MemoizedCounterComponent = React.memo(CounterComponent) -``` +See [`useSelector`: Stale Props and "Zombie Children"](./useSelector.md#usage-warnings) for the full explanation. ## Hooks Recipes @@ -551,7 +125,7 @@ This hook was in our original alpha release, but removed in `v7.1.0-alpha.4`, ba That suggestion was based on "binding action creators" not being as useful in a hooks-based use case, and causing too much conceptual overhead and syntactic complexity. -You should probably prefer to call the [`useDispatch`](#usedispatch) hook in your components to retrieve a reference to `dispatch`, +You should probably prefer to call the [`useDispatch`](./useDispatch.md) hook in your components to retrieve a reference to `dispatch`, and manually call `dispatch(someActionCreator())` in callbacks and effects as needed. You may also use the Redux [`bindActionCreators`](https://redux.js.org/api/bindactioncreators) function in your own code to bind action creators, or "manually" bind them like `const boundAddTodo = (text) => dispatch(addTodo(text))`. diff --git a/docs/api/unwrap.md b/docs/api/unwrap.md new file mode 100644 index 000000000..038587c86 --- /dev/null +++ b/docs/api/unwrap.md @@ -0,0 +1,75 @@ +--- +id: unwrap +title: unwrap +sidebar_label: unwrap() +hide_title: true +description: 'API > unwrap: resolving useSignalSelector tracking proxies to raw state objects' +--- + +  + +# `unwrap()` + +```ts +const rawValue: T = unwrap(value: T) +``` + +_added in v9.4.0_ + +Resolves a [`useSignalSelector`](./useSignalSelector.md) tracking proxy to the raw state object it wraps. Safe to call on any value: non-proxy values (including primitives and `null`) are returned unchanged. + +## API Reference + +### Parameters + +| Name | Description | +| :------ | :------------------------------------------------ | +| `value` | The value to unwrap - a proxy or any other value. | + +### Returns + +The raw state object if `value` is a tracking proxy, otherwise `value` itself. + +## Usage Guide + +Inside a `useSignalSelector` selector, nested objects read from `state` are tracking proxies. A proxy is never `===` to the raw object it wraps, so identity comparisons against object references captured _outside_ the current selector run always fail. `unwrap` resolves the proxy so the comparison works: + +```ts +import { unwrap, useSignalSelector } from 'react-redux' + +const selected = useSignalSelector((state) => { + // ❌ always false: `item` is a proxy, `currentItem` is a raw object + // return state.items.find((item) => item === currentItem) + + // ✅ compare raw object to raw object + return state.items.find((item) => unwrap(item) === currentItem) +}) +``` + +The other use case is intentionally keeping a reference to a state object outside the selector (in a module-level variable, a ref, or a dispatched action payload built inside the selector). Unwrap it first, so you store the raw object rather than a proxy tied to a previous selector run: + +```ts +let lastSeenUser = null + +const userName = useSignalSelector((state) => { + lastSeenUser = unwrap(state.auth.currentUser) + return state.auth.currentUser.name +}) +``` + +You do not need `unwrap` for: + +- **Selector return values.** The hook unwraps them automatically - components, equality functions, and effects always receive plain state values. +- **Comparisons between two values both read from `state` in the same selector run.** Proxy identities are consistent within a run. +- **Anything in `useSelector`.** The standard hook never sees proxies; `unwrap` is a pass-through there. + +:::caution + +Reading fields from an unwrapped object inside the selector bypasses dependency tracking - changes to those fields will not re-run the selector. Unwrap as late as possible, and only for identity comparisons or for storing a reference, not for general field access. + +::: + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md), especially the [Proxies and identity](./useSignalSelector.md#proxies-and-identity) section +- [`SignalProvider`](./SignalProvider.md) diff --git a/docs/api/useDispatch.md b/docs/api/useDispatch.md new file mode 100644 index 000000000..33550d1c0 --- /dev/null +++ b/docs/api/useDispatch.md @@ -0,0 +1,110 @@ +--- +id: useDispatch +title: useDispatch +sidebar_label: useDispatch() +hide_title: true +description: 'API > useDispatch: dispatching actions from React components' +--- + +  + +# `useDispatch()` + +```ts +import type { Dispatch } from 'redux' +const dispatch: Dispatch = useDispatch() +``` + +_added in v7.1.0_ + +This hook returns a reference to the `dispatch` function from the Redux store. You may use it to dispatch actions as needed. + +## API Reference + +### Parameters + +`useDispatch` takes no arguments. + +### Returns + +The `dispatch` function from the Redux store provided by the nearest [``](./Provider.md). + +:::info + +The `dispatch` function reference will be stable as long as the same store instance is being passed to the ``. +Normally, that store instance never changes in an application. + +However, the React hooks lint rules do not know that `dispatch` should be stable, and will warn that the `dispatch` variable +should be added to dependency arrays for `useEffect` and `useCallback`. The simplest solution is to do just that: + +```js +export const Todos = () => { + const dispatch = useDispatch() + + useEffect(() => { + dispatch(fetchTodos()) + // highlight-start + // Safe to add dispatch to the dependencies array + }, [dispatch]) + // highlight-end +} +``` + +::: + +## Usage Guide + +### Examples + +```jsx +import React from 'react' +import { useDispatch } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const dispatch = useDispatch() + + return ( +
+ {value} + +
+ ) +} +``` + +When passing a callback using `dispatch` to a child component, you may sometimes want to memoize it with [`useCallback`](https://react.dev/reference/react/useCallback). _If_ the child component is trying to optimize render behavior using `React.memo()` or similar, this avoids unnecessary rendering of child components due to the changed callback reference. + +```jsx +import React, { useCallback } from 'react' +import { useDispatch } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const dispatch = useDispatch() + const incrementCounter = useCallback( + () => dispatch({ type: 'increment-counter' }), + [dispatch], + ) + + return ( +
+ {value} + +
+ ) +} + +export const MyIncrementButton = React.memo(({ onIncrement }) => ( + +)) +``` + +### TypeScript + +`useDispatch` accepts a pre-typed setup via `useDispatch.withTypes()`, which lets you define the store's `dispatch` type once (including thunk support) instead of specifying it at every call site. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSelector`](./useSelector.md) and [`useStore`](./useStore.md): the other core React Redux hooks +- [Hooks overview](./hooks.md) diff --git a/docs/api/useSelector.md b/docs/api/useSelector.md new file mode 100644 index 000000000..48673632e --- /dev/null +++ b/docs/api/useSelector.md @@ -0,0 +1,410 @@ +--- +id: useSelector +title: useSelector +sidebar_label: useSelector() +hide_title: true +description: 'API > useSelector: reading values from the Redux store state with a selector function' +--- + +  + +# `useSelector()` + +```ts +const result: Selected = useSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +_added in v7.1.0_ + +Allows you to extract data from the Redux store state for use in this component, using a selector function. + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const CounterComponent = () => { + const counter = useSelector((state) => state.counter) + return
{counter}
+} +``` + +:::info + +The selector function should be [pure](https://en.wikipedia.org/wiki/Pure_function) since it is potentially executed multiple times and at arbitrary points in time. + +See [Using Redux: Deriving Data with Selectors](https://redux.js.org/usage/deriving-data-selectors) in the Redux docs for more details on writing and using selector functions. + +::: + +## API Reference + +### Signature + +```ts +type RootState = ReturnType +type SelectorFn = (state: RootState) => Selected +type EqualityFn = (a: any, b: any) => boolean +export type DevModeCheckFrequency = 'never' | 'once' | 'always' + +interface UseSelectorOptions { + equalityFn?: EqualityFn + devModeChecks?: { + stabilityCheck?: DevModeCheckFrequency + identityFunctionCheck?: DevModeCheckFrequency + } +} + +const result: Selected = useSelector( + selector: SelectorFn, + options?: EqualityFn | UseSelectorOptions +) +``` + +### Parameters + +| Name | Description | +| :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `selector` | A function that receives the entire Redux store state as its only argument, and returns the value this component needs from it. | +| `options?` | Either an equality function used to compare selector results (such as `shallowEqual`), or an options object containing an `equalityFn` field and a [`devModeChecks`](#development-mode-checks) field for configuring the development-mode checks below. | + +### Returns + +The return value of the selector function, recalculated whenever the component renders with a new selector reference or a dispatched action changes the selected value. + +## Usage Guide + +### Selector behavior + +The selector will be called with the entire Redux store state as its only argument. The selector may return any value as a result, including directly returning a value that was nested inside `state`, or deriving new values. The return value of the selector will be used as the return value of the `useSelector()` hook. + +The selector will be run whenever the function component renders (unless its reference hasn't changed since a previous render of the component so that a cached result can be returned by the hook without re-running the selector). `useSelector()` will also subscribe to the Redux store, and run your selector whenever an action is dispatched. + +When an action is dispatched, `useSelector()` will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render. `useSelector()` uses strict `===` reference equality checks by default, not shallow equality (see the following section for more details). + +The selector is approximately equivalent to the [`mapStateToProps` argument to `connect`](../using-react-redux/connect-extracting-data-with-mapStateToProps.md) conceptually. + +You may call `useSelector()` multiple times within a single function component. Each call to `useSelector()` creates an individual subscription to the Redux store. Because of the React update batching behavior used in React Redux v7, a dispatched action that causes multiple `useSelector()`s in the same component to return new values _should_ only result in a single re-render. + +:::info + +There are potential edge cases with using props in selectors that may cause issues. See the [Usage Warnings](#usage-warnings) section of this page for further details. + +::: + +### Equality Comparisons and Updates + +When the function component renders, the provided selector function will be called and its result will be returned +from the `useSelector()` hook. (A cached result may be returned by the hook without re-running the selector if it's the same function reference as on a previous render of the component.) + +However, when an action is dispatched to the Redux store, `useSelector()` only forces a re-render if the selector result +appears to be different than the last result. The default comparison is a strict `===` reference +comparison. This is different than `connect()`, which uses shallow equality checks on the results of `mapState` calls +to determine if re-rendering is needed. This has several implications on how you should use `useSelector()`. + +With `mapState`, all individual fields were returned in a combined object. It didn't matter if the return object was +a new reference or not - `connect()` just compared the individual fields. With `useSelector()`, returning a new object +every time will _always_ force a re-render by default. If you want to retrieve multiple values from the store, you can: + +- Call `useSelector()` multiple times, with each call returning a single field value +- Use Reselect or a similar library to create a memoized selector that returns multiple values in one object, but + only returns a new object when one of the values has changed. +- Use the `shallowEqual` function from React-Redux as the `equalityFn` argument to `useSelector()`, like: + +```js +import { shallowEqual, useSelector } from 'react-redux' + +// Pass it as the second argument directly +const selectedData = useSelector(selectorReturningObject, shallowEqual) + +// or pass it as the `equalityFn` field in the options argument +const selectedData = useSelector(selectorReturningObject, { + equalityFn: shallowEqual, +}) +``` + +- Use a custom equality function as the `equalityFn` argument to `useSelector()`, like: + +```js +import { useSelector } from 'react-redux' + +// equality function +const customEqual = (oldValue, newValue) => oldValue === newValue + +// later +const selectedData = useSelector(selectorReturningObject, customEqual) +``` + +The optional comparison function also enables using something like Lodash's `_.isEqual()` or Immutable.js's comparison capabilities. + +### Examples + +Basic usage: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const CounterComponent = () => { + const counter = useSelector((state) => state.counter) + return
{counter}
+} +``` + +Using props via closure to determine what to extract: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const TodoListItem = (props) => { + const todo = useSelector((state) => state.todos[props.id]) + return
{todo.text}
+} +``` + +### Using memoizing selectors + +When using `useSelector` with an inline selector as shown above, a new instance of the selector is created whenever the component is rendered. This works as long as the selector does not maintain any state. However, memoizing selectors (e.g. created via `createSelector` from `reselect`) do have internal state, and therefore care must be taken when using them. Below you can find typical usage scenarios for memoizing selectors. + +When the selector does only depend on the state, simply ensure that it is declared outside of the component so that the same selector instance is used for each render: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' +import { createSelector } from 'reselect' + +const selectNumCompletedTodos = createSelector( + (state) => state.todos, + (todos) => todos.filter((todo) => todo.completed).length, +) + +export const CompletedTodosCounter = () => { + const numCompletedTodos = useSelector(selectNumCompletedTodos) + return
{numCompletedTodos}
+} + +export const App = () => { + return ( + <> + Number of completed todos: + + + ) +} +``` + +The same is true if the selector depends on the component's props, but will only ever be used in a single instance of a single component: + +```jsx +import React from 'react' +import { useSelector } from 'react-redux' +import { createSelector } from 'reselect' + +const selectCompletedTodosCount = createSelector( + (state) => state.todos, + (_, completed) => completed, + (todos, completed) => + todos.filter((todo) => todo.completed === completed).length, +) + +export const CompletedTodosCount = ({ completed }) => { + const matchingCount = useSelector((state) => + selectCompletedTodosCount(state, completed), + ) + + return
{matchingCount}
+} + +export const App = () => { + return ( + <> + Number of done todos: + + + ) +} +``` + +However, when the selector is used in multiple component instances and depends on the component's props, you need to ensure that selector's memoization behavior is properly configured (see [here](https://reselect.js.org/faq/#can-i-share-a-selector-across-multiple-component-instances) for details). + +### Development mode checks + +`useSelector` runs some extra checks in development mode to watch for unexpected behavior. These checks do not run in production builds. + +:::info + +These checks were first added in v8.1.0 + +::: + +#### Selector result stability + +In development, the provided selector function is run an extra time with the same parameter during the first call to `useSelector`, and warns in the console if the selector returns a different result (based on the `equalityFn` provided). + +This is important, as **a selector that returns a different result reference when called again with the same inputs will cause unnecessary rerenders**. + +```ts +// this selector will return a new object reference whenever called, +// which causes the component to rerender after *every* action is dispatched +const { count, user } = useSelector((state) => ({ + count: state.count, + user: state.user, +})) +``` + +If a selector result is suitably stable (or the selector is memoized), it will not return a different result and no warning will be logged. + +By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. + +```tsx title="Global setting via context" + + {children} + +``` + +```tsx title="Individual hook setting" +function Component() { + const count = useSelector(selectCount, { + devModeChecks: { stabilityCheck: 'never' }, + }) + // run once (default) + const user = useSelector(selectUser, { + devModeChecks: { stabilityCheck: 'once' }, + }) + // ... +} +``` + +#### Identity Function (`state => state`) Check + +:::danger Breaking Change! + +This was previously referred to as `noopCheck`. + +::: + +In development, a check is conducted on the result returned by the selector. It warns in the console if the result is the same as the parameter passed in, i.e. the root state. + +**A `useSelector` call returning the entire root state is almost always a mistake**, as it means the component will rerender whenever _anything_ in state changes. Selectors should be as granular as possible, like `state => state.some.nested.field`. + +```ts no-transpile +// BAD: this selector returns the entire state, meaning that the component will rerender unnecessarily +const { count, user } = useSelector((state) => state) + +// GOOD: instead, select only the state you need, calling useSelector as many times as needed +const count = useSelector((state) => state.count.value) +const user = useSelector((state) => state.auth.currentUser) +``` + +By default, this will only happen when the selector is first called. You can configure the check in the Provider or at each `useSelector` call. + +```tsx title="Global setting via context" + + {children} + +``` + +```tsx title="Individual hook setting" +function Component() { + const count = useSelector(selectCount, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + // run once (default) + const user = useSelector(selectUser, { + devModeChecks: { identityFunctionCheck: 'once' }, + }) + // ... +} +``` + +### Comparisons with `connect` + +There are some differences between the selectors passed to `useSelector()` and a `mapState` function: + +- The selector may return any value as a result, not just an object. +- The selector normally _should_ return just a single value, and not an object. If you do return an object or an array, be sure to use a memoized selector to avoid unnecessary re-renders. +- The selector function does _not_ receive an `ownProps` argument. However, props can be used through closure (see the examples above) or by using a curried selector. +- You can use the `equalityFn` option to customize the comparison behavior + +### Usage Warnings + +#### Stale Props and "Zombie Children" + +:::info + +The React-Redux hooks API has been production-ready since we released it in v7.1.0, and **we recommend using the hooks API as the default approach in your components**. However, there are a couple of edge cases that can occur, and **we're documenting those so that you can be aware of them**. + +In practice, these are a rare concern - we've received far more comments about these being in the docs than actual reports of these being a real problem in an app. + +::: + +One of the most difficult aspects of React Redux's implementation is ensuring that if your `mapStateToProps` function is defined as `(state, ownProps)`, it will be called with the "latest" props every time. Up through version 4, there were recurring bugs reported involving edge case situations, such as errors thrown from a `mapState` function for a list item whose data had just been deleted. + +Starting with version 5, React Redux has attempted to guarantee that consistency with `ownProps`. In version 7, that is implemented using a custom `Subscription` class internally in `connect()`, which forms a nested hierarchy. This ensures that connected components lower in the tree will only receive store update notifications once the nearest connected ancestor has been updated. However, this relies on each `connect()` instance overriding part of the internal React context, supplying its own unique `Subscription` instance to form that nesting, and rendering the `` with that new context value. + +With hooks, there is no way to render a context provider, which means there's also no nested hierarchy of subscriptions. Because of this, the "stale props" and "zombie child" issues may potentially re-occur in an app that relies on using hooks instead of `connect()`. + +Specifically, "stale props" means any case where: + +- a selector function relies on this component's props to extract data +- a parent component _would_ re-render and pass down new props as a result of an action +- but this component's selector function executes before this component has had a chance to re-render with those new props + +Depending on what props were used and what the current store state is, this _may_ result in incorrect data being returned from the selector, or even an error being thrown. + +"Zombie child" refers specifically to the case where: + +- Multiple nested connected components are mounted in a first pass, causing a child component to subscribe to the store before its parent +- An action is dispatched that deletes data from the store, such as a todo item +- The parent component _would_ stop rendering that child as a result +- However, because the child subscribed first, its subscription runs before the parent stops rendering it. When it reads a value from the store based on props, that data no longer exists, and if the extraction logic is not careful, this may result in an error being thrown. + +`useSelector()` tries to deal with this by catching all errors that are thrown when the selector is executed due to a store update (but not when it is executed during rendering). When an error occurs, the component will be forced to render, at which point the selector is executed again. This works as long as the selector is a pure function and you do not depend on the selector throwing errors. + +If you prefer to deal with this issue yourself, here are some possible options for avoiding these problems altogether with `useSelector()`: + +- Don't rely on props in your selector function for extracting data +- In cases where you do rely on props in your selector function _and_ those props may change over time, _or_ the data you're extracting may be based on items that can be deleted, try writing the selector functions defensively. Don't just reach straight into `state.todos[props.id].name` - read `state.todos[props.id]` first, and verify that it exists before trying to read `todo.name`. +- Because `connect` adds the necessary `Subscription` to the context provider and delays evaluating child subscriptions until the connected component has re-rendered, putting a connected component in the component tree just above the component using `useSelector` will prevent these issues as long as the connected component gets re-rendered due to the same store update as the hooks component. + +:::info + +For a longer description of these scenarios, see: + +- ["Stale props and zombie children in Redux" by Kai Hao](https://kaihao.dev/posts/Stale-props-and-zombie-children-in-Redux) +- [A chat log that describes the problems in more detail](https://gist.github.com/markerikson/faac6ae4aca7b82a058e13216a7888ec) +- [issue #1179](https://github.com/reduxjs/react-redux/issues/1179) + +::: + +### Performance + +As mentioned earlier, by default `useSelector()` will do a reference equality comparison of the selected value when running the selector function after an action is dispatched, and will only cause the component to re-render if the selected value changed. However, unlike `connect()`, `useSelector()` does not prevent the component from re-rendering due to its parent re-rendering, even if the component's props did not change. + +If further performance optimizations are necessary, you may consider wrapping your function component in `React.memo()`: + +```jsx +const CounterComponent = ({ name }) => { + const counter = useSelector((state) => state.counter) + return ( +
+ {name}: {counter} +
+ ) +} + +export const MemoizedCounterComponent = React.memo(CounterComponent) +``` + +### TypeScript + +`useSelector` accepts a pre-typed setup via `useSelector.withTypes()`, which lets you define the `state` type once instead of specifying it in every selector. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSignalSelector`](./useSignalSelector.md): a fine-grained variant of this hook that tracks which parts of the state each selector reads +- [`useDispatch`](./useDispatch.md) and [`useStore`](./useStore.md): the other core React Redux hooks +- [Hooks overview](./hooks.md) diff --git a/docs/api/useSignalSelector.md b/docs/api/useSignalSelector.md new file mode 100644 index 000000000..28205b209 --- /dev/null +++ b/docs/api/useSignalSelector.md @@ -0,0 +1,209 @@ +--- +id: useSignalSelector +title: useSignalSelector +sidebar_label: useSignalSelector() +hide_title: true +description: 'API > useSignalSelector: fine-grained state selection with automatic dependency tracking' +--- + +  + +# `useSignalSelector()` + +```ts +const result: Selected = useSignalSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +_added in v9.4.0_ + +A fine-grained variant of [`useSelector`](./useSelector.md). It reads a value from the Redux store state using a selector function, exactly like `useSelector` - but it also automatically tracks _which fields of the state the selector actually read_, and only re-runs the selector when one of those fields changes. + +:::info Requires `SignalProvider` + +`useSignalSelector` only works inside a [``](./SignalProvider.md), which maintains the signal graph the hook depends on. Calling it under a standard `` throws an error. All other React Redux APIs (`useSelector`, `useDispatch`, `connect`) work unchanged under ``, so the two hooks can be mixed freely during a migration. + +::: + +```jsx +import React from 'react' +import { useSignalSelector } from 'react-redux' + +export const CounterComponent = () => { + // Re-runs only when `state.counter` changes - + // dispatches that change other parts of the state are skipped entirely + const counter = useSignalSelector((state) => state.counter) + return
{counter}
+} +``` + +With `useSelector`, every dispatched action re-runs every subscribed selector, and the results are compared to decide whether to re-render. With `useSignalSelector`, the selector receives a tracking proxy of the state. Each field access is recorded, and on later dispatches the selector is only re-run if one of the recorded fields actually changed. For apps with many mounted components, this can significantly reduce the work done per dispatch. + +## API Reference + +### Signature + +The signature is identical to [`useSelector`](./useSelector.md): + +```ts +type DevModeCheckFrequency = 'never' | 'once' | 'always' + +interface UseSelectorOptions { + equalityFn?: EqualityFn + devModeChecks?: { + stabilityCheck?: DevModeCheckFrequency + identityFunctionCheck?: DevModeCheckFrequency + } +} + +const result: Selected = useSignalSelector( + selector: (state: RootState) => Selected, + options?: EqualityFn | UseSelectorOptions +) +``` + +### Parameters + +Both parameters behave exactly as they do in [`useSelector`](./useSelector.md#parameters): + +| Name | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | +| `selector` | A function that receives the entire Redux store state and returns the value this component needs. Must be [pure](./useSelector.md). | +| `options?` | Either an equality function (such as `shallowEqual`), or an options object with `equalityFn` and `devModeChecks` fields, as in `useSelector`. | + +The [development mode checks](./useSelector.md#development-mode-checks) (`stabilityCheck` and `identityFunctionCheck`) work the same way, and can be configured globally on `` or per hook call. + +### Returns + +The return value of the selector function. Returned values are always plain Redux state values - the tracking proxies used during selector evaluation never leak into your components (see [Proxies and identity](#proxies-and-identity)). + +## Usage Guide + +### How it works + +1. On the first run, the selector is called with a tracking proxy wrapping the state. Every field the selector reads (`state.todos`, `todo.completed`, and so on) is recorded as a dependency path. +2. On every dispatch, [``](./SignalProvider.md) diffs the previous and next state and marks the changed paths. +3. The selector re-runs only if one of its recorded paths changed. If none did, the hook returns the cached result without calling the selector at all. +4. Each run re-records dependencies from scratch, so selectors with conditional logic stay correct as their reads change. + +This is the same family of technique used by `proxy-memoize` and by fine-grained reactivity systems like Solid and Vue, applied to Redux state. + +### When to use it + +`useSignalSelector` helps most when: + +- Many components are mounted at once, each selecting small slices of a large state +- Your app dispatches frequently, and most dispatches are irrelevant to most components +- Selectors read narrow, deep paths (`state.entities.todos[id].completed`) + +It helps least (and can cost slightly more than `useSelector`) when most dispatches change state that most components read anyway, or when selectors read broad swaths of the state. Tracked selector evaluation itself costs roughly 3-4x an untracked run, so the win comes from the runs that are _skipped_, not from the runs themselves. + +Because `useSelector` and `useSignalSelector` coexist under one ``, you can adopt it incrementally in the components with the highest subscription counts. + +### Differences from `useSelector` + +The observable behavior - what your components render, when they re-render, what values they receive - matches `useSelector` in almost all cases. The differences fall into three groups. + +#### Hard constraints + +| Constraint | Explanation | +| :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Requires ``** | Calling `useSignalSelector` under a plain `` throws. | +| **The state root must be a plain object** | Path tracking operates on the root object's keys. If the root is not a plain object, the hook still works, but falls back to untracked evaluation (behaving like `useSelector`). | +| **`state => state` never updates** | A selector that returns the entire root state records no field reads, so no dispatch will ever re-run it. This is already an anti-pattern (`useSelector`'s [identity function check](./useSelector.md#identity-function-state--state-check) warns about it), but with `useSignalSelector` it goes from "wasteful" to "broken": the component will not update. The dev-mode check catches this. | +| **Selectors must not mutate state** | Writing to the state from inside a selector (`state.items.sort()`, `state.foo = 1`, `delete state.bar`) throws a `TypeError` in development explaining which property the selector tried to change. `useSelector` silently allows these writes, which corrupt the store; `useSignalSelector` rejects them because the proxy sees every write. Use non-mutating alternatives (`state.items.slice().sort()`, `toSorted()`). | +| **Mutations inside `Map`, `Set`, `Date`, and class instances are not tracked** | Only plain objects and arrays get per-field tracking. Other object types are tracked by reference: replacing the instance triggers an update, but reading `myMap.get('key')` does not record a per-entry dependency. Since Redux state should be [immutably updated](https://redux.js.org/style-guide/#do-not-mutate-state) plain data anyway, this mostly matters for state produced by libraries. | + +#### Gotchas + +These are behaviors that differ from `useSelector` in ways you might notice, but that have straightforward workarounds - or need no workaround at all. + +##### Proxies and identity + +Inside the selector, nested objects you read from `state` are tracking proxies, not the raw state objects. The values are identical; the object identity is not. This matters only for `===` comparisons **inside the selector body** against object references obtained _outside_ the current selector run: + +```ts +import { unwrap, useSignalSelector } from 'react-redux' + +const selected = useSignalSelector((state) => { + // ❌ `state.items` entries are proxies; `previouslySavedItem` is raw. + // This comparison is always false: + return state.items.find((item) => item === previouslySavedItem) +}) + +const selected = useSignalSelector((state) => { + // ✅ unwrap() resolves a proxy to its raw object: + return state.items.find((item) => unwrap(item) === previouslySavedItem) +}) +``` + +See [`unwrap`](./unwrap.md) for details. Comparisons between two values both read from `state` in the same selector run work correctly without unwrapping. + +Elements _returned_ from array methods (`state.items.find(...)`, `state.items.filter(...)`, `state.items.map(x => x)`) are raw state objects, not proxies, so they compare correctly against outside references. Only the values you read directly off `state` and inside array method callbacks are proxied. + +**Proxies never escape the selector.** The hook unwraps the selector's return value before handing it to React, so your components, effects, equality functions, and dispatched actions always see plain Redux state. `console.log` of a selector result shows plain data. For the same reason, avoid _storing_ a state object read during one selector run in an external variable for use later - the same rule as holding onto an Immer draft. Use `unwrap()` first if you need to do this. + +##### Memoized (Reselect) selectors + +Memoized selectors created with Reselect's `createSelector` work with `useSignalSelector`, following the same rules as with [`useSelector`](./useSelector.md#using-memoizing-selectors), including selectors shared between many components and selectors that take extra arguments. Cached result values are unwrapped the same way as direct returns. + +There is one difference in _how much_ memoization you get. Dependency tracking only works when the selector actually reads from the state proxy, so the hook hands each tracked run a fresh root proxy. Reselect's argument-level cache (`argsMemoize`, keyed on the `state` argument) therefore misses on every tracked run, and the **input selectors run every time**. The **result function still memoizes**: nested state objects keep stable identities across runs, so when the inputs have not changed, the cached result is returned without recomputing. In practice this means a memoized selector costs a little more per tracked run than under `useSelector`, and runs far less often. + +Two consequences follow from this: + +- Selectors that cache _without_ reading state on a cache hit (a hand-rolled `let cached` guard, for example) cannot be tracked and will not update. The same selector is also stale under `useSelector`, just less visibly. +- Inside a result function, the inputs are proxies. Reading one field of a proxied input repeatedly in a loop (`comments.filter(c => c.postId === post.id)`) costs a proxy trap per iteration. Repeated reads of the same field within one run are memoized, so this is usually cheap, but hoisting the value into a local (`const postId = post.id`) is cheaper still. In development, a selector that re-reads the same field 100+ times in one run logs a warning pointing at the path. + +##### Coarse-grained fallback for broad selectors + +Selectors that enumerate the root state's keys (`Object.keys(state)`, spreading `{...state}`) or otherwise read the root without narrowing to specific fields cannot be tracked at a useful granularity. They still work correctly, but re-evaluate more often and set up their tracking eagerly, costing more at mount. Narrow selectors are both the best practice and the fast path. + +Enumerating or spreading a _nested_ object (`{...state.user}`, `Object.keys(state.entities)`) is tracked precisely: the selector depends on the object's set of keys plus each field the spread copies. + +##### Aliased state objects + +If the same object instance is reachable via two different state paths (for example, an entity stored in two lookup tables), tracking attributes reads to the first path encountered. The consequence is only ever _extra_ selector re-runs, never missed updates. + +##### Stale props and "zombie children" + +`useSignalSelector` handles the [stale props / zombie children scenarios](./useSelector.md#usage-warnings) with the same user-visible outcome as `useSelector`: an error thrown from a selector during a store update does not crash the dispatch; the component re-renders and the selector re-runs with the latest props, and only a persistent error propagates to your error boundary. The internal mechanism differs (there is no nested `Subscription` hierarchy under ``), but the guidance from the `useSelector` docs - prefer defensive reads like checking `state.todos[props.id]` before using it - applies unchanged. + +##### Dev-mode stability warning shows raw state + +When the [selector stability check](./useSelector.md#selector-result-stability) fires, the logged `state` argument is the raw state object, not the tracking proxy, so inspecting it in the console does not register phantom dependencies. The warning's meaning and configuration are identical to `useSelector`. + +#### Performance characteristics + +- **Tracked evaluation costs roughly 3-4x an untracked run.** The payoff is the dispatches where the selector does not run at all. Apps where most dispatches are irrelevant to most components come out well ahead; apps where every dispatch touches state that every component reads gain nothing and pay the diff cost inside each dispatch. +- **Array methods track elements by identity, and scan callbacks by column.** `find`, `findLast`, `filter`, `slice`, and `map` return _raw_ elements, each with a dependency on that element's identity: replacing the element (which any immutable update to it does) re-runs the selector, but the individual fields you read off a returned element are not tracked separately. Scan callbacks (`find(item => item.done)`, `some`, `every`, `findIndex`, and so on) depend on the one column they read rather than every field of every element. Reads two levels deep inside a callback (`item.meta.done`), and methods that are not overridden (`forEach`, `reduce`, `flatMap`), fall back to depending on the array as a whole - still correct, just coarser. Direct index access (`state.items[0].name`) keeps full field-level precision. +- **Conditional selectors track only the branch they took.** Dependencies are re-recorded from scratch on every run, so a selector like `state.mode === 'a' ? state.a.x : state.b.y` depends on `mode` and on whichever of `a.x` or `b.y` it read most recently. Switching branches drops the old branch's paths. + +### Migrating an app with a bundler alias + +The `react-redux/signals` entry point re-exports the entire `react-redux` API, with `useSelector` aliased to `useSignalSelector` and `Provider` aliased to `SignalProvider`. This makes trying signals across a whole app a one-line bundler config change instead of a search-and-replace: + +```js title="vite.config.js" +export default defineConfig({ + resolve: { + alias: { + 'react-redux': 'react-redux/signals', + }, + }, +}) +``` + +The explicit names (`useSignalSelector`, `SignalProvider`, `unwrap`) are also exported from both entry points, so code written against either style keeps working. + +Before flipping the alias for a whole app, audit for the patterns listed above - particularly selectors returning the root state, identity comparisons against captured references inside selectors, and in-place reads of `Map`/`Set`/class-instance state. + +### TypeScript + +`useSignalSelector` supports the same pre-typed setup as `useSelector` via `useSignalSelector.withTypes()`. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`SignalProvider`](./SignalProvider.md): required to use this hook +- [`unwrap`](./unwrap.md): resolving tracking proxies for identity comparisons +- [`useSelector`](./useSelector.md): the standard selector hook this one mirrors diff --git a/docs/api/useStore.md b/docs/api/useStore.md new file mode 100644 index 000000000..f5a524161 --- /dev/null +++ b/docs/api/useStore.md @@ -0,0 +1,66 @@ +--- +id: useStore +title: useStore +sidebar_label: useStore() +hide_title: true +description: 'API > useStore: accessing the Redux store instance directly' +--- + +  + +# `useStore()` + +```ts +import type { Store } from 'redux' +const store: Store = useStore() +``` + +_added in v7.1.0_ + +This hook returns a reference to the same Redux store that was passed in to the `` component. + +This hook should probably not be used frequently. Prefer [`useSelector()`](./useSelector.md) as your primary choice. However, this may be useful for less common scenarios that do require access to the store, such as replacing reducers. + +## API Reference + +### Parameters + +`useStore` takes no arguments. + +### Returns + +The Redux store instance provided by the nearest [``](./Provider.md). + +## Usage Guide + +### Examples + +```js +import React from 'react' +import { useStore } from 'react-redux' + +export const ExampleComponent = ({ value }) => { + const store = useStore() + + const onClick = () => { + // Not _recommended_, but safe + // This avoids subscribing to the state via `useSelector` + // Prefer moving this logic into a thunk instead + const numTodos = store.getState().todos.length + } + + // EXAMPLE ONLY! Do not do this in a real app. + // The component will not automatically update if the store state changes + return
{store.getState().todos.length}
+} +``` + +### TypeScript + +`useStore` accepts a pre-typed setup via `useStore.withTypes()`, which lets you define the store type once instead of specifying it at every call site. See [Usage with TypeScript](../using-react-redux/usage-with-typescript.md) for details on defining pre-typed hooks. + +## See Also + +- [`useSelector`](./useSelector.md) and [`useDispatch`](./useDispatch.md): the other core React Redux hooks +- [Accessing the Store](../using-react-redux/accessing-store.md): other techniques for store access, including custom context +- [Hooks overview](./hooks.md) diff --git a/docs/using-react-redux/accessing-store.md b/docs/using-react-redux/accessing-store.md index 6f4bed7bf..d2cc56288 100644 --- a/docs/using-react-redux/accessing-store.md +++ b/docs/using-react-redux/accessing-store.md @@ -30,7 +30,7 @@ React Redux's `` component uses `` to put ## Using the `useStore` Hook -The [`useStore` hook](../api/hooks.md#usestore) returns the current store instance from the default `ReactReduxContext`. If you truly need to access the store, this is the recommended approach. +The [`useStore` hook](../api/useStore.md) returns the current store instance from the default `ReactReduxContext`. If you truly need to access the store, this is the recommended approach. ## Providing Custom Context diff --git a/examples/publish-ci/rr-tree-shaking/.gitignore b/examples/publish-ci/rr-tree-shaking/.gitignore new file mode 100644 index 000000000..a9654f244 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/dist +*.tgz +*.tsbuildinfo +/.size diff --git a/examples/publish-ci/rr-tree-shaking/index.html b/examples/publish-ci/rr-tree-shaking/index.html new file mode 100644 index 000000000..31b806c5f --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/index.html @@ -0,0 +1,11 @@ + + + + + React-Redux tree-shaking check + + +
+ + + diff --git a/examples/publish-ci/rr-tree-shaking/package.json b/examples/publish-ci/rr-tree-shaking/package.json new file mode 100644 index 000000000..b7ebcf8cb --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/package.json @@ -0,0 +1,24 @@ +{ + "name": "rr-tree-shaking", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.23.0", + "scripts": { + "build": "vite build && node scripts/check-tree-shaking.mjs", + "measure": "node scripts/measure-size.mjs" + }, + "dependencies": { + "react": "19.2.8", + "react-dom": "19.2.8", + "react-redux": "^9.3.0", + "redux": "^5.0.1" + }, + "devDependencies": { + "@types/react": "19.2.18", + "@types/react-dom": "19.2.7", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "5.9.3", + "vite": "^8.2.2" + } +} diff --git a/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml b/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml new file mode 100644 index 000000000..f009bc9c7 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/pnpm-lock.yaml @@ -0,0 +1,966 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-redux: + specifier: ^9.3.0 + version: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1) + redux: + specifier: ^5.0.1 + version: 5.0.1 + devDependencies: + '@types/react': + specifier: 19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: 19.2.7 + version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@8.3.0) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.2 + version: 8.3.0 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + baseline-browser-mapping@2.11.25: + resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.0: + resolution: {integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.431: + resolution: {integrity: sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.56: + resolution: {integrity: sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-redux@9.3.0: + resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.150.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@vitejs/plugin-react@5.2.0(vite@8.3.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 8.3.0 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.11.25: {} + + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.25 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.431 + node-releases: 2.0.56 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + + caniuse-lite@1.0.30001810: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.431: {} + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-releases@2.0.56: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.8 + use-sync-external-store: 1.7.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + redux: 5.0.1 + + react-refresh@0.18.0: {} + + react@19.2.8: {} + + redux@5.0.1: {} + + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.9.3: {} + + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.7.0(react@19.2.8): + dependencies: + react: 19.2.8 + + vite@8.3.0: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml b/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml new file mode 100644 index 000000000..68548cbee --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# This example is an independent project, not part of the root pnpm project. +# +# pnpm finds a project's root by walking up until it finds a +# pnpm-workspace.yaml. Without this file, pnpm run from here walks up to the +# repo root and operates on the react-redux project instead of this example. +# This file stops that walk. + +nodeLinker: hoisted diff --git a/examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs b/examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs new file mode 100644 index 000000000..57eda7664 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/scripts/check-tree-shaking.mjs @@ -0,0 +1,64 @@ +// Scans the Vite build output and fails if any of the signals implementation +// survived tree-shaking in an app that only uses the stock React-Redux API. +// +// The signals code lives on the main entry point next to the stock exports, so +// every consumer relies on the bundler dropping it. That only works while the +// signals modules have no module-level side effects. A refactor that adds one +// (for example, destructuring the result of a top-level call) breaks it +// silently, and this check is what catches that. + +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const distDir = path.resolve(import.meta.dirname, '..', 'dist', 'assets') + +// Identifiers that exist only in the signals implementation. The build runs +// unminified, so function and class names survive into the output. +const forbidden = [ + 'createReactiveSystem', + 'createPathSignalRegistry', + 'createTrackingProxy', + 'reconcileState', + 'SignalProvider', + 'useSignalSelector', + 'alien-signals', +] + +// Something the stock implementation always ships. If this is missing the +// build did not include react-redux at all and the forbidden check proves +// nothing. +const required = ['useSyncExternalStoreWithSelector', 'ReactReduxContext'] + +const files = (await readdir(distDir)).filter((f) => f.endsWith('.js')) +if (files.length === 0) { + console.error(`No JS output found in ${distDir}. Did \`vite build\` run?`) + process.exit(1) +} + +const output = ( + await Promise.all(files.map((f) => readFile(path.join(distDir, f), 'utf8'))) +).join('\n') + +const missing = required.filter((marker) => !output.includes(marker)) +if (missing.length > 0) { + console.error( + 'Build output does not look like a react-redux app. Missing markers:', + ) + for (const marker of missing) console.error(` - ${marker}`) + process.exit(1) +} + +const leaked = forbidden.filter((marker) => output.includes(marker)) +if (leaked.length > 0) { + console.error('Signals code leaked into a stock-only build. Found:') + for (const marker of leaked) console.error(` - ${marker}`) + console.error( + '\nSomething in src/signals now has a module-level side effect that bundlers cannot drop.', + ) + process.exit(1) +} + +const bytes = Buffer.byteLength(output) +console.log( + `Tree-shaking OK: ${files.length} file(s), ${bytes} bytes, no signals code present.`, +) diff --git a/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs new file mode 100644 index 000000000..6f3c14b00 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/scripts/measure-size.mjs @@ -0,0 +1,104 @@ +// Builds the example app three ways with Vite (minified) and reports the size +// of the react-redux + redux portion of the output. React itself is external. +// +// stock - Provider + useSelector + useDispatch + connect +// signals - SignalProvider + useSignalSelector + useDispatch + connect +// alias - same source as `stock`, with 'react-redux' aliased to +// 'react-redux/signals' +// +// Run after installing the tarball under test: `pnpm measure` + +import { gzipSync } from 'node:zlib' +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { build } from 'vite' +import react from '@vitejs/plugin-react' + +const here = path.resolve(import.meta.dirname, '..') + +const installed = await import('react-redux') +if (!('useSignalSelector' in installed)) { + console.error( + 'The installed react-redux has no signals exports. Install a build from the signals branch first:\n' + + ' pnpm remove react-redux && pnpm add ./package.tgz', + ) + process.exit(1) +} + +const stockSource = await readFile(path.join(here, 'src/main.tsx'), 'utf8') +const signalsSource = stockSource + .replace( + 'Provider, connect, useDispatch, useSelector', + 'SignalProvider, connect, useDispatch, useSignalSelector', + ) + .replaceAll('useSelector(', 'useSignalSelector(') + .replaceAll('', '
') + +const variants = [ + { name: 'stock', source: stockSource, alias: {} }, + { name: 'signals', source: signalsSource, alias: {} }, + { + name: 'alias', + source: stockSource, + alias: { 'react-redux': 'react-redux/signals' }, + }, +] + +const reactExternals = ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'] + +async function measure(variant, { externalizeReactRedux }) { + // Entries must live inside the project so bare imports resolve to its + // node_modules. + const work = path.join(here, '.size', variant.name) + await mkdir(work, { recursive: true }) + await writeFile(path.join(work, 'main.tsx'), variant.source) + const outDir = path.join(work, externalizeReactRedux ? 'dist-without' : 'dist-with') + + await build({ + root: here, + logLevel: 'silent', + configFile: false, + plugins: [react()], + resolve: { alias: variant.alias }, + build: { + outDir, + emptyOutDir: true, + minify: true, + rollupOptions: { + input: path.join(work, 'main.tsx'), + external: externalizeReactRedux + ? [...reactExternals, /^react-redux(\/|$)/] + : reactExternals, + }, + }, + }) + + const assets = path.join(outDir, 'assets') + const files = (await readdir(assets)).filter((f) => f.endsWith('.js')) + const code = ( + await Promise.all(files.map((f) => readFile(path.join(assets, f)))) + ).join('') + return { min: Buffer.byteLength(code), gz: gzipSync(code).byteLength } +} + +// The app code and `redux` are identical whether or not react-redux is +// bundled, so the difference between the two builds is the react-redux cost +// alone, including its own dependencies (use-sync-external-store, +// alien-signals). Doing the subtraction on the gzipped totals is the closest +// available approximation of the gzipped cost. +const results = [] +for (const variant of variants) { + const withRR = await measure(variant, { externalizeReactRedux: false }) + const withoutRR = await measure(variant, { externalizeReactRedux: true }) + results.push({ + variant: variant.name, + 'react-redux min': withRR.min - withoutRR.min, + 'react-redux min+gz': withRR.gz - withoutRR.gz, + 'app total min': withRR.min, + 'app total min+gz': withRR.gz, + }) +} + +await rm(path.join(here, '.size'), { recursive: true, force: true }) +console.table(results) diff --git a/examples/publish-ci/rr-tree-shaking/src/main.tsx b/examples/publish-ci/rr-tree-shaking/src/main.tsx new file mode 100644 index 000000000..af14dd1cf --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/src/main.tsx @@ -0,0 +1,36 @@ +import { createRoot } from 'react-dom/client' +import { Provider, connect, useDispatch, useSelector } from 'react-redux' +import { createStore } from 'redux' + +// A stock React-Redux app: Provider, hooks, and connect, all from the main +// entry point. It never touches SignalProvider or useSignalSelector, so a +// correct build must not contain any of the signals implementation. + +interface State { + count: number +} + +function reducer(state: State = { count: 0 }, action: { type: string }) { + return action.type === 'increment' ? { count: state.count + 1 } : state +} + +const store = createStore(reducer) + +function Counter() { + const count = useSelector((s: State) => s.count) + const dispatch = useDispatch() + return ( + + ) +} + +const ConnectedLabel = connect((s: State) => ({ count: s.count }))( + ({ count }: { count: number }) => {count}, +) + +createRoot(document.getElementById('root')!).render( + + + + , +) diff --git a/examples/publish-ci/rr-tree-shaking/tsconfig.json b/examples/publish-ci/rr-tree-shaking/tsconfig.json new file mode 100644 index 000000000..4a7e9c42d --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src", "vite.config.mts"] +} diff --git a/examples/publish-ci/rr-tree-shaking/vite.config.mts b/examples/publish-ci/rr-tree-shaking/vite.config.mts new file mode 100644 index 000000000..d1f36bf36 --- /dev/null +++ b/examples/publish-ci/rr-tree-shaking/vite.config.mts @@ -0,0 +1,17 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], + build: { + // The check script looks for function names in the output. Minification + // would mangle them, so keep identifiers intact. Tree-shaking does not + // depend on minification, so this still measures what an app would ship. + minify: false, + // React itself is not what this example is checking. Leaving it out keeps + // the output small enough to inspect by hand when the check fails. + rollupOptions: { + external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'], + }, + }, +}) diff --git a/package.json b/package.json index 3b9816286..12edd77bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "9.3.0", + "version": "9.4.0-alpha.2", "description": "Official React bindings for Redux", "keywords": [ "react", @@ -29,6 +29,11 @@ "types": "./dist/react-redux.d.ts", "import": "./dist/react-redux.mjs", "default": "./dist/cjs/index.js" + }, + "./signals": { + "types": "./dist/react-redux-signals.d.ts", + "import": "./dist/react-redux-signals.mjs", + "default": "./dist/cjs/signals.js" } }, "sideEffects": false, @@ -45,6 +50,8 @@ "lint:fix": "oxlint src test --fix", "prepack": "pnpm build", "test": "vitest --run --typecheck", + "test:signals": "cross-env TEST_IMPL=signals vitest --run", + "test:both": "pnpm test && pnpm test:signals", "test:watch": "vitest --watch", "type-tests": "tsc --noEmit -p tsconfig.test.json" }, @@ -63,11 +70,12 @@ }, "dependencies": { "@types/use-sync-external-store": "^0.0.6", + "alien-signals": "^3.2.1", "use-sync-external-store": "^1.4.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.5", - "@reduxjs/toolkit": "^2.12.0", + "@reduxjs/toolkit": "^2.13.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.10.0", "@testing-library/react": "^16.3.3", @@ -83,6 +91,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "redux": "^5.0.1", + "reselect": "^5.2.0", "rimraf": "^6.1.3", "tsdown": "^0.22.14", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48dc6284a..440b4de71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@types/use-sync-external-store': specifier: ^0.0.6 version: 0.0.6 + alien-signals: + specifier: ^3.2.1 + version: 3.2.1 use-sync-external-store: specifier: ^1.4.0 version: 1.6.0(react@19.2.8) @@ -19,8 +22,8 @@ importers: specifier: ^0.18.5 version: 0.18.5 '@reduxjs/toolkit': - specifier: ^2.12.0 - version: 2.12.0(react@19.2.8) + specifier: ^2.13.0 + version: 2.13.0(react@19.2.8) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -66,6 +69,9 @@ importers: redux: specifier: ^5.0.1 version: 5.0.1 + reselect: + specifier: ^5.2.0 + version: 5.3.0 rimraf: specifier: ^6.1.3 version: 6.1.3 @@ -408,8 +414,8 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} - '@reduxjs/toolkit@2.12.0': - resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} + '@reduxjs/toolkit@2.13.0': + resolution: {integrity: sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==} peerDependencies: react: ^16.9.0 || ^17.0.0 || ^18 || ^19 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 @@ -751,6 +757,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -1929,7 +1938,7 @@ snapshots: dependencies: quansync: 1.0.0 - '@reduxjs/toolkit@2.12.0(react@19.2.8)': + '@reduxjs/toolkit@2.13.0(react@19.2.8)': dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 @@ -2168,6 +2177,8 @@ snapshots: agent-base@7.1.4: {} + alien-signals@3.2.1: {} + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f2e354a4..dd80e995c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,3 +22,6 @@ autoInstallPeers: false # is `allowBuilds` (a map); pnpm 10's `onlyBuiltDependencies` list is ignored. allowBuilds: esbuild: true + +minimumReleaseAgeExclude: + - '@reduxjs/toolkit@2.13.0' diff --git a/src/exports.ts b/src/exports.ts index e3a027b08..bfc108328 100644 --- a/src/exports.ts +++ b/src/exports.ts @@ -9,6 +9,7 @@ import shallowEqual from './utils/shallowEqual' import Provider from './components/Provider' import { defaultNoopBatch } from './utils/batch' +import { SignalProvider, useSignalSelector, unwrap } from './signals' export { ReactReduxContext } from './components/Context' export type { ReactReduxContextValue } from './components/Context' @@ -48,4 +49,13 @@ export * from './types' */ const batch = defaultNoopBatch -export { Provider, batch, connect, legacy_connect, shallowEqual } +export { + Provider, + batch, + connect, + legacy_connect, + shallowEqual, + SignalProvider, + unwrap, + useSignalSelector, +} diff --git a/src/index-rsc.ts b/src/index-rsc.ts index 47722081e..42c40338e 100644 --- a/src/index-rsc.ts +++ b/src/index-rsc.ts @@ -30,6 +30,9 @@ export { throwNotSupportedError as useDispatch, throwNotSupportedError as useSelector, throwNotSupportedError as useStore, + throwNotSupportedError as SignalProvider, + throwNotSupportedError as unwrap, + throwNotSupportedError as useSignalSelector, } export const ReactReduxContext = {} as any export { default as shallowEqual } from './utils/shallowEqual' diff --git a/src/index-signals.ts b/src/index-signals.ts new file mode 100644 index 000000000..87fe0677b --- /dev/null +++ b/src/index-signals.ts @@ -0,0 +1,29 @@ +/** + * `react-redux/signals` entry point. + * + * Presents the signals implementation under the stock public names, so a + * bundler alias can swap an entire app onto the signals implementation + * without touching application code: + * + * ```js + * // vite.config.js + * export default defineConfig({ + * resolve: { + * alias: { 'react-redux': 'react-redux/signals' }, + * }, + * }) + * ``` + * + * Everything else (`connect`, `useDispatch`, `useStore`, types) is + * re-exported unchanged from the main entry. The explicit exports below + * shadow the corresponding names from `export *`. + */ +export * from './index' + +export { + SignalProvider as Provider, + useSignalSelector as useSelector, + createSignalSelectorHook as createSelectorHook, +} from './signals' + +export type { SignalProviderProps as ProviderProps } from './signals' diff --git a/src/signals/SignalProvider.tsx b/src/signals/SignalProvider.tsx new file mode 100644 index 000000000..54296b936 --- /dev/null +++ b/src/signals/SignalProvider.tsx @@ -0,0 +1,110 @@ +import type { Context } from 'react' +import { React } from '../utils/react' +import type { Action, UnknownAction } from 'redux' +import type { ReactReduxContextValue } from '../components/Context' +import { ReactReduxContext } from '../components/Context' +import type { ProviderProps } from '../components/Provider' +import { createSubscription } from '../utils/Subscription' +import { useIsomorphicLayoutEffect } from '../utils/useIsomorphicLayoutEffect' +import type { SignalContextValue } from './context' +import { createPathSignalRegistry } from './pathSignalRegistry' +import { reconcileState } from './diff' +import { alienEngine } from './engine' + +export interface SignalProviderProps< + A extends Action = UnknownAction, + S = unknown, +> extends ProviderProps {} + +export function SignalProvider< + A extends Action = UnknownAction, + S extends object = object, +>(providerProps: SignalProviderProps) { + const { children, context, serverState, store } = providerProps + + // The signal graph and the diff baseline are keyed to the store: + // swapping the `store` prop starts over with a fresh registry, the + // same way stock Provider rebuilds its Subscription. `prevState` is + // mutable — onStateChange advances it after each diff. + const signalState = React.useMemo( + () => ({ + registry: createPathSignalRegistry(alienEngine), + prevState: store.getState() as S, + }), + [store], + ) + const registry = signalState.registry + + // Build context value: standard ReactReduxContextValue + signal fields + const contextValue = React.useMemo(() => { + const subscription = createSubscription(store) + + const baseContextValue: SignalContextValue = { + store, + subscription, + getServerState: serverState ? () => serverState : undefined, + registry, + } + + if (process.env.NODE_ENV === 'production') { + return baseContextValue + } else { + const { identityFunctionCheck = 'once', stabilityCheck = 'once' } = + providerProps + + return Object.assign(baseContextValue, { + stabilityCheck, + identityFunctionCheck, + }) + } + }, [store, serverState, registry]) + + const previousState = React.useMemo(() => store.getState(), [store]) + + // Standard Provider subscription logic (from components/Provider.tsx) + // + signal diff on each dispatch + useIsomorphicLayoutEffect(() => { + const { subscription } = contextValue + subscription.onStateChange = () => { + // Run signal diff BEFORE notifying nested subs, so computed values + // are up-to-date when useSelector/useSignalSelector read them + const prev = signalState.prevState + const next = store.getState() + signalState.prevState = next + const changedRootKeys = reconcileState(prev, next, registry, alienEngine) + + // Coarse tier: wake deferred subscribers whose top-level segments + // changed (null = root wasn't diffable, every subscriber is a + // candidate). Runs before notifyNestedSubs so a promoted hook's + // deep graph and current result are ready when React reads its + // snapshot. collect() returns a snapshot array — subscribers + // unregister themselves from the index inside onCoarseHit. + if (prev !== next && registry.segmentIndex.size() > 0) { + const hits = registry.segmentIndex.collect(changedRootKeys) + for (let i = 0; i < hits.length; i++) { + hits[i].onCoarseHit() + } + } + + subscription.notifyNestedSubs() + } + subscription.trySubscribe() + + if (previousState !== store.getState()) { + subscription.notifyNestedSubs() + } + return () => { + subscription.tryUnsubscribe() + subscription.onStateChange = undefined + } + }, [contextValue, previousState]) + + const Context = (context || + ReactReduxContext) as Context | null> + + return ( + }> + {children} + + ) +} diff --git a/src/signals/arrayKeys.ts b/src/signals/arrayKeys.ts new file mode 100644 index 000000000..e11343ffa --- /dev/null +++ b/src/signals/arrayKeys.ts @@ -0,0 +1,169 @@ +/** + * Identity-based array tracking utilities. + * + * When array elements have ID fields (e.g., {id: 42, name: "Alice"}), + * signals use identity-based paths (items.{id:42}.name) instead of + * index-based paths (items.0.name). This makes signals stable across + * reorders, insertions, and removals. + */ + +/** Candidate key field names, checked in priority order. */ +const KEY_CANDIDATES = ['id', 'key', '_id', '__id'] as const + +/** + * Detect an identity key field on an object. + * Returns the property name if found, undefined otherwise. + * Checks 'id' > 'key' > '_id' > '__id' — same priority order as Legend State. + * @param obj - The object to inspect for a key field + * @returns The key field name, or undefined if none found + */ +export function findKeyField(obj: unknown): string | undefined { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) + return undefined + for (let i = 0; i < KEY_CANDIDATES.length; i++) { + const candidate = KEY_CANDIDATES[i] + if (candidate in (obj as Record)) { + const value = (obj as Record)[candidate] + // Key must be a primitive that can be used in a path string + if (typeof value === 'string' || typeof value === 'number') { + return candidate + } + } + } + return undefined +} + +/** + * Per-array metadata for identity-based tracking. + * Stored in the registry, keyed by the array's parent path. + */ +export interface ArrayMeta { + /** The property name used as the identity key (e.g., 'id') */ + keyField: string + /** + * Map from key value → previous array element reference. + * Used by diff to match elements across prev/next arrays by identity. + */ + entityMap: Map +} + +// Characters that corrupt path bookkeeping if they appear in a path +// segment (an object key or an identity key value): +// '.' breaks the ancestor walks (lastIndexOf('.')), '{'/'}' can collide +// with identity/column segments, '%' is the escape character itself, +// '"' is reserved for quoting numeric-looking strings (a literal quote +// in a key would otherwise collide with the quoted form of another key), +// and '@' guards meta segments — a state key literally named '@@keys' +// must not collide with the `parent.@@keys` meta signal. +const NEEDS_ESCAPE = /[%.{}"@]/ + +/** + * Encode a single path segment so it can be safely joined with '.' into + * a path string. Reserved characters are %-escaped. The common case + * (no reserved characters) returns the input string unchanged. + * Used for object property keys, identity key values, and column props. + * @param s - The raw segment (object key or stringified key value) + * @returns The encoded segment + */ +export function encodePathSegment(s: string): string { + if (!NEEDS_ESCAPE.test(s)) return s + return s + .replace(/%/g, '%25') + .replace(/\./g, '%2E') + .replace(/\{/g, '%7B') + .replace(/\}/g, '%7D') + .replace(/"/g, '%22') + .replace(/@/g, '%40') +} + +/** + * Join an ALREADY-ENCODED segment (or an array index) onto a parent + * path. The root object's path is the empty string, so a root-level + * segment is the path by itself. Callers must run object keys through + * `encodePathSegment` first; array indexes need no encoding. + * @param parentPath - Path of the containing object ('' for the root) + * @param segment - Encoded segment or array index + * @returns The joined path key + */ +export function joinPath(parentPath: string, segment: string | number): string { + return parentPath ? parentPath + '.' + segment : String(segment) +} + +/** + * Path of the `@@keys` meta signal for an object/array. Built raw (NOT + * via encodePathSegment): '@@keys' is a meta segment, and encoding + * would turn it into an ordinary (escaped) property path. A state key + * literally named '@@keys' encodes to '%40%40keys' and cannot collide. + * @param parentPath - Path of the object whose key set is tracked + * @returns The meta path key + */ +export function keysMetaPath(parentPath: string): string { + return parentPath ? parentPath + '.@@keys' : '@@keys' +} + +/** + * Render a key value as a path segment fragment. + * + * Two invariants: + * 1. No '.', '{', or '}' in the output (escaped as %XX) so path + * prefix walking and pruning stay correct for ids like "a@b.com". + * 2. A string key never renders identically to a number key. Strings + * whose escaped form matches a number's rendering (e.g. "1", "1.5") + * are wrapped in quotes: number 1 → `1`, string "1" → `"1"`. + * @param keyValue - The identity key value + * @returns The encoded segment fragment + */ +function encodeKeyValue(keyValue: string | number): string { + if (typeof keyValue === 'number') { + // Integers (the common case) can't contain escapable characters + if (Number.isInteger(keyValue)) return String(keyValue) + return encodePathSegment(String(keyValue)) + } + const escaped = encodePathSegment(keyValue) + // Quote strings that would collide with a number's rendering + return String(Number(keyValue)) === keyValue ? '"' + escaped + '"' : escaped +} + +/** + * Build the identity path segment for an array element. + * e.g., arrayPath="items", keyField="id", element={id: 42} → "items.{id:42}" + * Key values are encoded: dots/braces are %-escaped, and numeric-looking + * string keys are quoted to stay distinct from number keys. + * @param arrayPath - Parent array's path + * @param keyField - The identity key field name + * @param keyValue - The identity key value + * @returns The identity-based path string + */ +export function buildIdentityPath( + arrayPath: string, + keyField: string, + keyValue: string | number, +): string { + const segment = `{${keyField}:${encodeKeyValue(keyValue)}}` + return arrayPath ? `${arrayPath}.${segment}` : segment +} + +/** + * Extract a key value from an array element. + * Returns undefined if element is not an object or key field is missing. + * @param element - The array element to extract the key from + * @param keyField - The identity key field name + * @returns The key value, or undefined if not extractable + */ +export function getKeyValue( + element: unknown, + keyField: string, +): string | number | undefined { + if ( + element === null || + typeof element !== 'object' || + Array.isArray(element) + ) { + return undefined + } + const value = (element as Record)[keyField] + if (typeof value === 'string' || typeof value === 'number') { + return value + } + return undefined +} diff --git a/src/signals/arrayMethodOverrides.ts b/src/signals/arrayMethodOverrides.ts new file mode 100644 index 000000000..b2f876235 --- /dev/null +++ b/src/signals/arrayMethodOverrides.ts @@ -0,0 +1,518 @@ +import { throwStateMutationError } from './mutationError' +import type { PathSignalRegistry, StructureKind } from './pathSignalRegistry' +import { + getElementPathKey, + getProxyPath, + getProxyTarget, + registerRecordingHolder, + unwrap, +} from './trackingProxy' + +/** + * Non-mutating array methods that we override on the tracking proxy. + * + * The key optimization: callbacks scan elements WITHOUT per-element proxy + * creation or per-element signal registration, and elements that leave + * the array through a method (find, filter, slice, map's `x => x`) leave + * as RAW objects with one identity dependency each on the element's path. + * + * Example: `state.items.find(i => i.id === 42)` scans 1000 items (fast), + * then returns the raw match and depends on `items.{id:42}`. + * + * **Why raw elements:** state is immutable, so any change inside an + * element replaces the element and fires its identity signal. Tracking + * individual fields of an element after it has left the array would be + * more precise but costs a proxy trap plus a signal registration per + * read — and elements handed to downstream code (Reselect result + * functions, `.filter` on the returned array) are typically read + * wholesale, thousands of reads per evaluation. The identity dependency + * can over-run the selector (a change to a field it didn't read), never + * miss an update; the result equality check keeps over-runs from + * becoming renders. Direct index access (`items[0].name`) still returns + * a tracking proxy and keeps field-level precision. + * + * **Dependency tracking for scans:** + * - Callback methods (find, filter, some, every, findIndex, ...) pass a + * lightweight *scan recorder* proxy to the callback. It records WHICH + * properties the callback reads (e.g. `done` in `find(x => x.done)`) and + * forwards values from the raw element. After the scan, one *column signal* + * per (array, property) pair is registered — e.g. `items.{*}.done`. The + * diff fires a column signal when any element's value for that property + * changes, or when membership changes. This keeps scans O(N) with O(props) + * signals instead of O(N×props). + * - If the callback reads nested objects, enumerates keys, or elements are + * primitives, we fall back to a coarse whole-array version signal (fires + * on any descendant change — may over-fire, but the selector's `===` + * result check bails out cheaply). + * - Non-callback methods that depend on all elements (includes, indexOf, + * join, concat, slice, ...) always register the coarse array signal. + * + * Categories: + * - Subset operations (find, findLast, filter, slice): return raw elements + * with identity dependencies + * - map: scans through the recorder; results that are elements (own or + * from another tracked object) come back raw with identity dependencies + * - Primitive-returning (findIndex, indexOf, some, every, includes, etc.): return as-is + * - Transform operations (concat, flat): return raw values (new structures, not subsets) + * - Pass-through (forEach, reduce, flatMap): NOT overridden — callbacks need tracking + */ + +type SubsetMethod = 'find' | 'findLast' | 'filter' | 'slice' | 'map' +type PrimitiveMethod = + | 'findIndex' + | 'findLastIndex' + | 'some' + | 'every' + | 'indexOf' + | 'lastIndexOf' + | 'includes' + | 'join' + | 'toString' + | 'toLocaleString' +type TransformMethod = 'concat' | 'flat' + +type OverriddenMethod = SubsetMethod | PrimitiveMethod | TransformMethod + +const FIND_METHODS = new Set(['find', 'findLast']) + +/** Methods whose element dependencies come from what the callback reads. */ +const CALLBACK_METHODS = new Set([ + 'find', + 'findLast', + 'filter', + 'map', + 'findIndex', + 'findLastIndex', + 'some', + 'every', +]) + +const OVERRIDDEN_METHODS = new Set([ + // Subset — return raw elements with identity dependencies + 'find', + 'findLast', + 'filter', + 'slice', + 'map', + // Primitive-returning + 'findIndex', + 'findLastIndex', + 'some', + 'every', + 'indexOf', + 'lastIndexOf', + 'includes', + 'join', + 'toString', + 'toLocaleString', + // Transform — return raw values + 'concat', + 'flat', +]) + +/** + * Check if a property name is an overridden array method. + * @param prop - The property name to check + * @returns Whether the property is an overridden array method + */ +export function isOverriddenArrayMethod(prop: string): boolean { + return OVERRIDDEN_METHODS.has(prop) +} + +function normalizeSliceIndex(index: number, length: number): number { + if (index < 0) { + return Math.max(length + index, 0) + } + return Math.min(index, length) +} + +/** + * A reusable recorder for array scan callbacks. One proxy is created per + * method call (not per element); it reads from `holder.current`, which is + * swapped to each element as the scan advances. + */ +interface ScanRecorder { + proxy: object + holder: { current: unknown } + /** String property names the callback read on elements. */ + recorded: Set + /** True when column tracking can't capture the callback's reads + * (nested object reads, key enumeration, primitive elements). */ + fallback: boolean +} + +function createScanRecorder(): ScanRecorder { + const holder: { current: unknown } = { current: undefined } + const recorder: ScanRecorder = { + proxy: null as unknown as object, + holder, + recorded: new Set(), + fallback: false, + } + + recorder.proxy = new Proxy({} as Record, { + get(_obj, prop) { + const current = holder.current as Record + if (typeof prop === 'symbol') return current[prop] + recorder.recorded.add(prop) + const value = current[prop] + if (value !== null && typeof value === 'object') { + // Nested object read — column signals only cover depth-1 property + // values. Fall back to the coarse array signal for correctness. + recorder.fallback = true + } + return value + }, + has(_obj, prop) { + if (typeof prop === 'string') recorder.recorded.add(prop) + return Reflect.has(holder.current as object, prop) + }, + ownKeys() { + recorder.fallback = true + return Reflect.ownKeys(holder.current as object) + }, + getOwnPropertyDescriptor(_obj, prop) { + recorder.fallback = true + const desc = Object.getOwnPropertyDescriptor( + holder.current as object, + prop, + ) + // Frozen state props are non-configurable; the recorder's dummy + // target doesn't have them, so report configurable to satisfy + // proxy invariants. + return desc ? { ...desc, configurable: true } : desc + }, + getPrototypeOf() { + return Object.getPrototypeOf(holder.current as object) + }, + set(_obj, prop) { + throwStateMutationError('set', String(prop)) + }, + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) + }, + }) + + registerRecordingHolder(recorder.proxy, holder) + return recorder +} + +/** + * Present an element to a scan callback: objects go through the recorder + * proxy (so property reads are recorded), primitives pass through raw + * (and force coarse fallback since column tracking can't cover them). + * @param recorder - The scan recorder for this method call + * @param value - The raw element value + * @returns The value to pass to the user callback + */ +function presentElement(recorder: ScanRecorder, value: unknown): unknown { + if (value !== null && typeof value === 'object') { + recorder.holder.current = value + return recorder.proxy + } + recorder.fallback = true + return value +} + +/** + * Which structural change kinds each (method, outcome) pair depends on. + * + * Semantics per method (outcome = did the scan find a match?): + * - find/matched: first match at position i — appends can't precede it, + * removals of earlier elements are non-matching by definition, and + * removal of the match itself fires its element signals via prune. + * Only inserts/reorders before the match can change the result. + * - find/missed: a new element anywhere could become the first match. + * - findLast: appends can also change the result (new last match). + * - findIndex/matched: like find/matched, but removals shift the index. + * - findLastIndex/matched: appends and removals both shift the index. + * - filter: membership of the result changes with any structural change. + * - some/true: only removal of the witness can flip true→false + * (replacements fire remove too). Adds can't flip true. + * - some/false: a new element could flip false→true. + * - every/true: a new element could flip true→false. + * - every/false: only removal of the counterexample can flip false→true. + * + * Value changes on elements are covered separately by column signals. + * Errors in this table must err toward including a kind (over-firing). + */ +const STRUCTURE_DEPS: Record< + string, + { matched: readonly StructureKind[]; missed: readonly StructureKind[] } +> = { + find: { + matched: ['insertOrReorder'], + missed: ['append', 'insertOrReorder'], + }, + findLast: { + matched: ['append', 'insertOrReorder'], + missed: ['append', 'insertOrReorder'], + }, + findIndex: { + matched: ['insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder'], + }, + findLastIndex: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder'], + }, + filter: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder', 'remove'], + }, + map: { + matched: ['append', 'insertOrReorder', 'remove'], + missed: ['append', 'insertOrReorder', 'remove'], + }, + some: { + matched: ['remove'], + missed: ['append', 'insertOrReorder'], + }, + every: { + // For every, "matched" = returned true (no counterexample found) + matched: ['append', 'insertOrReorder'], + // "missed" = returned false (counterexample exists) + missed: ['remove'], + }, +} + +/** + * After a scan completes, register the reactive dependencies it implies: + * column signals for each recorded property plus structural signals per + * the (method, outcome) matrix — or the coarse array signal when column + * tracking can't capture the callback's behavior. + * @param recorder - The scan recorder for this method call + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to the array in the state tree + * @param target - The raw frozen array + * @param method - The scan method that ran + * @param matched - Whether the scan found a match (see STRUCTURE_DEPS) + * @returns void + */ +function finalizeScanDeps( + recorder: ScanRecorder, + registry: PathSignalRegistry, + parentPath: string, + target: readonly unknown[], + method: OverriddenMethod, + matched: boolean, +): void { + if (recorder.fallback || recorder.recorded.size === 0) { + registry.getOrCreate(parentPath, target).get() + return + } + for (const prop of recorder.recorded) { + registry.trackColumn(parentPath, prop).get() + } + const kinds = STRUCTURE_DEPS[method] + if (kinds) { + const wanted = matched ? kinds.matched : kinds.missed + for (const kind of wanted) { + registry.trackStructure(parentPath, kind).get() + } + } +} + +/** + * Hand an element of this array to the caller: the raw value, plus an + * identity dependency on the element's path when it is an object. + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to the array in the state tree + * @param target - The raw frozen array + * @param index - Index of the element to emit + * @returns The raw element + */ +function emitElement( + registry: PathSignalRegistry, + parentPath: string, + target: readonly unknown[], + index: number, +): unknown { + const value = target[index] + if (value !== null && typeof value === 'object') { + registry + .getOrCreate(getElementPathKey(registry, parentPath, index, value), value) + .get() + } + return value +} + +/** + * Normalize a value returned from a `map` callback. A tracking proxy from + * elsewhere in the state tree (`ids.map(id => entities[id])`) comes back + * raw so the caller's downstream reads run on plain objects. Anything else + * is returned unchanged. + * + * The dependency goes on the proxy's parent container, not the element. + * Mapping a collection reads the whole container, and under immutability + * any element change replaces the container too, so the container version + * fires in exactly the cases a per-element dependency would. Per-element + * dependencies were strictly worse: when several hooks share a memoized + * collection selector, only the hook that hits the Reselect cache miss + * reads the elements, and the next time it re-runs on a cache hit it reads + * none of them — alien-signals then drops every element dependency, the + * registry releases every element signal, and the next collection change + * recreates them all. + * @param registry - Signal registry for dependency tracking + * @param value - The callback's return value + * @returns The value to place in the mapped array + */ +function emitMappedValue( + registry: PathSignalRegistry, + value: unknown, +): unknown { + if (value === null || typeof value !== 'object') return value + const raw = getProxyTarget(value) + if (raw === undefined) return value + const path = getProxyPath(value) + if (path !== undefined && path !== '') { + const dot = path.lastIndexOf('.') + // Object-valued signals hold version counters, so the seed value only + // needs to be an object; the raw element stands in for the container. + registry.getOrCreate(dot === -1 ? path : path.substring(0, dot), raw).get() + // The get trap recorded this element as a leaf-object candidate. The + // container dependency covers it, so keep the leaf tracker from adding + // a per-element identity signal on top. + registry.leafTrackerHolder.current?.traversedPaths.add(path) + } + return raw +} + +/** + * Create an interceptor for an array method that operates on the raw frozen + * array, bypassing per-element proxy creation. Elements that leave the + * array come back raw with identity dependencies (see module comment). + * + * @param target - The raw frozen array + * @param method - The method name being intercepted + * @param registry - Signal registry for dependency tracking + * @param parentPath - Path to this array in the state tree + * @returns An interceptor function that delegates to the raw array + */ +export function createArrayMethodInterceptor( + target: readonly unknown[], + method: string, + registry: PathSignalRegistry, + parentPath: string, +): (...args: unknown[]) => unknown { + return function intercepted(...args: unknown[]): unknown { + const m = method as OverriddenMethod + + // --- Callback-driven scans: record property reads, register column signals --- + + if (CALLBACK_METHODS.has(m)) { + const callback = args[0] as ( + value: unknown, + index: number, + array: readonly unknown[], + ) => unknown + const recorder = createScanRecorder() + + let result: unknown + let matched: boolean + + if (m === 'filter') { + const matches: unknown[] = [] + for (let i = 0; i < target.length; i++) { + if (callback(presentElement(recorder, target[i]), i, target)) { + matches.push(emitElement(registry, parentPath, target, i)) + } + } + result = matches + matched = matches.length > 0 + } else if (m === 'map') { + const mapped: unknown[] = [] + // The recorder proxy is reused across elements, so a callback + // that embeds its element in a fresh object (`x => ({ item: x })`) + // would capture a recorder pointing at whichever element is + // scanned last. Once a callback returns a fresh object, redo that + // element and scan the rest with raw elements under the coarse + // array dependency. + let rawMode = false + for (let i = 0; i < target.length; i++) { + const element = target[i] + if (!rawMode) { + const out = callback(presentElement(recorder, element), i, target) + if (out === recorder.proxy) { + mapped[i] = emitElement(registry, parentPath, target, i) + continue + } + if ( + out === null || + typeof out !== 'object' || + getProxyTarget(out) !== undefined + ) { + mapped[i] = emitMappedValue(registry, out) + continue + } + rawMode = true + recorder.fallback = true + } + mapped[i] = emitMappedValue(registry, callback(element, i, target)) + } + result = mapped + matched = true + } else if (FIND_METHODS.has(m)) { + const isForward = m === 'find' + const step = isForward ? 1 : -1 + const start = isForward ? 0 : target.length - 1 + result = undefined + for (let i = start; i >= 0 && i < target.length; i += step) { + if (callback(presentElement(recorder, target[i]), i, target)) { + result = emitElement(registry, parentPath, target, i) + break + } + } + matched = result !== undefined + } else { + // findIndex / findLastIndex / some / every — primitive results + result = (target as any)[m]( + (value: unknown, i: number, arr: readonly unknown[]) => + callback(presentElement(recorder, value), i, arr), + ) + if (m === 'findIndex' || m === 'findLastIndex') { + matched = result !== -1 + } else { + // some: true = witness found; every: true = no counterexample + matched = result === true + } + } + + finalizeScanDeps(recorder, registry, parentPath, target, m, matched) + return result + } + + // --- Non-callback methods depend on all elements: coarse array signal --- + // diffArray fires registry.update(parentPath, ...) for every visited + // array, so this signal fires on any descendant change. That's the + // right granularity: these methods read element identity or content + // wholesale (includes/indexOf compare refs, join/concat serialize all). + registry.getOrCreate(parentPath, target).get() + + if (m === 'slice') { + const rawStart = (args[0] as number) ?? 0 + const rawEnd = (args[1] as number) ?? target.length + const start = normalizeSliceIndex(rawStart, target.length) + const end = normalizeSliceIndex(rawEnd, target.length) + const result: unknown[] = [] + for (let i = start; i < end; i++) { + result.push(emitElement(registry, parentPath, target, i)) + } + return result + } + + // --- Identity-comparing methods: unwrap proxy arguments --- + + // indexOf/lastIndexOf/includes compare by === (SameValueZero for includes). + // If the search argument is a tracking proxy, unwrap it to the raw target + // so the comparison works against raw array elements. + if (m === 'includes' || m === 'indexOf' || m === 'lastIndexOf') { + const unwrappedArgs = args.map((arg, i) => (i === 0 ? unwrap(arg) : arg)) + return (target as any)[m](...unwrappedArgs) + } + + // --- Other primitive and transform operations: call on raw array --- + + return (target as any)[m](...args) + } +} diff --git a/src/signals/coarseSegments.ts b/src/signals/coarseSegments.ts new file mode 100644 index 000000000..f108fc011 --- /dev/null +++ b/src/signals/coarseSegments.ts @@ -0,0 +1,299 @@ +import { throwStateMutationError } from './mutationError' +import { registerProxyTarget } from './trackingProxy' + +/** + * A deferred (not-yet-built) useSignalSelector subscriber, registered in + * the coarse tier. The hook stays in this tier — no tracking proxies, no + * path signals, no alien-signals effect — until a dispatch changes one of + * the top-level state keys its selector read at probe time. Then + * `onCoarseHit` promotes it to the deep (path-signal) tier. + */ +export interface CoarseSub { + /** + * Top-level state keys the selector read when probed, recorded as raw + * (unencoded) key names. `null` means wildcard: the footprint can't be + * gated (e.g. the selector returned the root state), so the subscriber + * is hit on every dispatch that changes the root reference. + */ + segments: Set | null + /** Promote this subscriber to the deep tier. Called at most once. */ + onCoarseHit(): void +} + +/** + * Inverted index from top-level state key ("segment") to the coarse + * subscribers whose probed footprint includes that key. Lets the + * SignalProvider wake only the deferred subscribers that could possibly + * be affected by a dispatch, using the changed root keys the state diff + * already computes. + */ +export interface SegmentIndex { + register(sub: CoarseSub): void + /** Idempotent: unregistering a sub that isn't registered is a no-op. */ + unregister(sub: CoarseSub): void + /** + * Collect subscribers whose footprint intersects the changed root keys. + * `null` means the root wasn't diffable (non-plain-object root): every + * subscriber is a candidate. An empty array still returns wildcard + * subscribers — the root reference changed even if no key did. + * Returns a snapshot array: subscribers unregister themselves during + * `onCoarseHit`, so the caller must not iterate live index structures. + */ + collect(changedRootKeys: readonly string[] | null): readonly CoarseSub[] + size(): number +} + +const EMPTY: readonly CoarseSub[] = [] + +export function createSegmentIndex(): SegmentIndex { + const all = new Set() + const bySegment = new Map>() + const wildcards = new Set() + + return { + register(sub: CoarseSub): void { + if (all.has(sub)) return + all.add(sub) + if (sub.segments === null) { + wildcards.add(sub) + return + } + for (const seg of sub.segments) { + let bucket = bySegment.get(seg) + if (bucket === undefined) { + bucket = new Set() + bySegment.set(seg, bucket) + } + bucket.add(sub) + } + }, + + unregister(sub: CoarseSub): void { + if (!all.delete(sub)) return + if (sub.segments === null) { + wildcards.delete(sub) + return + } + for (const seg of sub.segments) { + const bucket = bySegment.get(seg) + if (bucket !== undefined) { + bucket.delete(sub) + if (bucket.size === 0) bySegment.delete(seg) + } + } + }, + + collect(changedRootKeys: readonly string[] | null): readonly CoarseSub[] { + if (all.size === 0) return EMPTY + if (changedRootKeys === null) return Array.from(all) + if (changedRootKeys.length === 0) { + return wildcards.size > 0 ? Array.from(wildcards) : EMPTY + } + const hits = new Set(wildcards) + for (let i = 0; i < changedRootKeys.length; i++) { + const bucket = bySegment.get(changedRootKeys[i]) + if (bucket !== undefined) { + for (const sub of bucket) hits.add(sub) + } + } + return hits.size > 0 ? Array.from(hits) : EMPTY + }, + + size(): number { + return all.size + }, + } +} + +/** + * Mutable record filled in while a selector runs against a probe proxy. + */ +export interface ProbeRecord { + /** Raw top-level state keys the selector touched (get / has / + * getOwnPropertyDescriptor). */ + segments: Set + /** The selector enumerated the root's keys (Object.keys, spread, + * for...in). Key-set changes can't be gated by the segments alone, + * so an enumerating selector must build the deep tier eagerly. */ + enumerated: boolean +} + +function isGuardable(value: unknown): value is object { + if (value === null || typeof value !== 'object') return false + if (Array.isArray(value)) return true + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} + +// Guards are cached by target identity so a probe run wraps each object +// once. Module-level: guards carry no per-probe state, and `untrackResult` +// strips them from results regardless of which probe created them. +const guardCache = new WeakMap() + +/** + * Dev-only read-only wrapper for nested objects handed out by the probe + * proxy. The deep tracking proxy rejects selector writes, but the probe + * returns nested values raw — without this guard, a mutating selector + * (e.g. `s => s.items.sort()`) silently corrupts real store state during + * the mount evaluation and only starts throwing after promotion. + * + * The guard records nothing (the probe already recorded the top-level + * segment); it exists purely to make write rejection consistent across + * both tiers. Plain objects and arrays only: wrapping Date/Map/Set/class + * instances breaks methods that need internal slots, and the deep tier + * returns those raw too. + * + * The proxy target is an unfrozen shell (same trick as the deep tracking + * proxy): the get trap returns nested guards, which would violate ES + * Proxy invariants on a frozen target's non-configurable properties. + * + * Registered in the proxy→target map so `untrackResult` and `unwrap` + * strip guards from selector results. + * @param target - The raw nested state object to guard + * @returns A proxy that reads through to `target` and throws on writes + */ +function createWriteGuard(target: T): T { + const cached = guardCache.get(target) + if (cached) return cached as T + + const shell = Array.isArray(target) + ? [] + : Object.create(Object.getPrototypeOf(target)) + + const proxy = new Proxy(shell as T, { + get(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.get(target, prop) + const value = (target as Record)[prop] + return isGuardable(value) ? createWriteGuard(value) : value + }, + + has(_obj, prop) { + return Reflect.has(target, prop) + }, + + ownKeys(_obj) { + return Reflect.ownKeys(target) + }, + + getOwnPropertyDescriptor(_obj, prop) { + const desc = Object.getOwnPropertyDescriptor(target, prop) + // Frozen state props are non-configurable; the shell doesn't have + // them, so report configurable to satisfy proxy invariants. + return desc ? { ...desc, configurable: true } : desc + }, + + getPrototypeOf(_obj) { + return Object.getPrototypeOf(target) + }, + + isExtensible(_obj) { + return Object.isExtensible(target) + }, + + set(_obj, prop) { + throwStateMutationError('set', String(prop)) + }, + + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) + }, + + defineProperty(_obj, prop) { + throwStateMutationError('defineProperty', String(prop)) + }, + + setPrototypeOf() { + throwStateMutationError('setPrototypeOf', '') + }, + }) as T + + guardCache.set(target, proxy) + registerProxyTarget(proxy as object, target) + return proxy +} + +/** + * Create a one-level-shallow probe proxy over the root state object. + * + * Unlike the deep tracking proxy, the probe: + * - records only WHICH top-level keys the selector reads (no signals, + * no path strings, no nested proxies) + * - returns raw nested values, so the selector runs at native speed + * below the first level (in production; dev builds wrap nested + * objects in write guards so mutating selectors throw at mount too) + * + * The proxy target is an unfrozen shell so the dev-mode get trap can + * return write guards without violating ES Proxy invariants on frozen + * state; the other traps read through to the real state object. + * + * The proxy is registered in the proxy→target map so `untrackResult` + * and `unwrap` strip it from selector results (`s => s`, + * `s => ({ root: s })`). + * + * Only plain-object roots may be probed: for class instances / Map / + * Set roots, method calls through the proxy would run with the proxy as + * `this` and break internal-slot access. Callers must check first. + * @param state - The root state object (plain object) + * @returns The probe proxy and the record it fills in + */ +export function createProbeProxy( + state: T, +): { proxy: T; record: ProbeRecord } { + const record: ProbeRecord = { segments: new Set(), enumerated: false } + + const shell = Object.create(Object.getPrototypeOf(state)) as T + + const proxy = new Proxy(shell, { + get(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.get(state, prop) + record.segments.add(prop) + const value = (state as Record)[prop] + if (process.env.NODE_ENV !== 'production' && isGuardable(value)) { + return createWriteGuard(value) + } + return value + }, + + has(_obj, prop) { + if (typeof prop !== 'symbol') record.segments.add(prop) + return Reflect.has(state, prop) + }, + + ownKeys(_obj) { + record.enumerated = true + return Reflect.ownKeys(state) + }, + + // Fires for Object.getOwnPropertyDescriptor and hasOwnProperty, and + // per-key during enumeration (where `enumerated` is already set by + // ownKeys). A single descriptor read reveals one key's existence and + // value — record it as a segment read, like get/has. + getOwnPropertyDescriptor(_obj, prop) { + if (typeof prop !== 'symbol') record.segments.add(prop) + const desc = Object.getOwnPropertyDescriptor(state, prop) + // The shell target doesn't have the state's props; report them + // configurable to satisfy proxy invariants (matters for frozen state). + return desc ? { ...desc, configurable: true } : desc + }, + + getPrototypeOf(_obj) { + return Object.getPrototypeOf(state) + }, + + isExtensible(_obj) { + return Object.isExtensible(state) + }, + + set(_obj, prop) { + throwStateMutationError('set', String(prop)) + }, + + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) + }, + }) as T + + registerProxyTarget(proxy as object, state) + + return { proxy, record } +} diff --git a/src/signals/context.ts b/src/signals/context.ts new file mode 100644 index 000000000..3b2024f96 --- /dev/null +++ b/src/signals/context.ts @@ -0,0 +1,55 @@ +import type { Action, UnknownAction } from 'redux' +import { React } from '../utils/react' +import type { ReactReduxContextValue } from '../components/Context' +import { ReactReduxContext } from '../components/Context' +import type { PathSignalRegistry } from './pathSignalRegistry' + +export interface SignalContextValue< + SS = any, + A extends Action = UnknownAction, +> extends ReactReduxContextValue { + registry: PathSignalRegistry +} + +export { ReactReduxContext } + +/** + * Hook factory, which creates a `useSignalContext` hook bound to a given + * context. Mirrors stock `createReduxContextHook`, but validates that the + * context value came from a `` (registry present). + * + * @param context - Context passed to your ``. + * @returns A `useSignalContext` hook bound to the given context. + */ +export function createSignalContextHook( + context: React.Context | null> = ReactReduxContext, +) { + return function useSignalContext(): SignalContextValue { + const contextValue = React.useContext( + context as unknown as React.Context | null>, + ) + + if (!contextValue) { + throw new Error( + 'useSignalSelector must be used within a ', + ) + } + + // Verify this is actually a signal context (has registry) + if (!('registry' in contextValue)) { + throw new Error( + 'useSignalSelector must be used within a , not a regular ', + ) + } + + return contextValue + } +} + +/** + * Reads the signal context bound to the default `ReactReduxContext`. + */ +export const useSignalContext = /* @__PURE__ */ createSignalContextHook() diff --git a/src/signals/diff.ts b/src/signals/diff.ts new file mode 100644 index 000000000..6f61fd7c8 --- /dev/null +++ b/src/signals/diff.ts @@ -0,0 +1,633 @@ +import { + buildIdentityPath, + encodePathSegment, + findKeyField, + getKeyValue, + joinPath, + keysMetaPath, +} from './arrayKeys' +import type { ArrayMeta } from './arrayKeys' +import type { PathSignalRegistry } from './pathSignalRegistry' +import type { SignalEngine } from './types' + +function isPlainObject(v: unknown): v is Record { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * Diff a plain object: recurse into changed properties. + * Separated from the main dispatcher for V8 monomorphic optimization. + * @param prev - Previous object state + * @param next - Next object state + * @param parentPath - Dot-separated path to this object in the state tree + * @param registry - Signal registry to update + * @param changedRootKeys - Root call only: collects the raw (unencoded) + * keys whose values changed, were added, or were removed. Feeds the + * coarse segment index — the ref-compare over root keys happens here + * anyway, so collection is a push per *changed* key, not a second pass. + * @returns void + */ +function diffObject( + prev: Record, + next: Record, + parentPath: string, + registry: PathSignalRegistry, + changedRootKeys?: string[], +): void { + const nextKeys = Object.keys(next) + const prevKeys = Object.keys(prev) + let keysChanged = prevKeys.length !== nextKeys.length + + if (parentPath) { + registry.update(parentPath, next) + } + + for (let i = 0; i < nextKeys.length; i++) { + const key = nextKeys[i] + + if (!keysChanged && !(key in prev)) { + keysChanged = true + } + + // Hoist reference equality check before path construction. + // Structural sharing means most keys are unchanged — skip early. + // The self-inequality test catches NaN (NaN !== NaN would otherwise + // descend and re-fire the leaf signal on every dispatch). + const prevVal = prev[key] + const nextVal = next[key] + if (prevVal === nextVal || (prevVal !== prevVal && nextVal !== nextVal)) { + // Added key whose value is `undefined`: ref-equal to the missing + // read (undefined === undefined), but `in` / hasOwnProperty gates + // on this key still flipped — the root collector must see it. The + // extra `in` check only runs for undefined-valued keys. + if ( + changedRootKeys !== undefined && + nextVal === undefined && + !(key in prev) + ) { + changedRootKeys.push(key) + } + continue + } + + if (changedRootKeys !== undefined) { + changedRootKeys.push(key) + } + + // Keys are encoded to match the proxy's pathKey construction — + // reserved path characters in state keys (dots, braces, '@') are + // %-escaped on both sides. + const childPath = joinPath(parentPath, encodePathSegment(key)) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prevVal, nextVal, childPath, registry) + } + } + + if (keysChanged) { + registry.update(keysMetaPath(parentPath), nextKeys) + + for (let i = 0; i < prevKeys.length; i++) { + if (!(prevKeys[i] in next)) { + if (changedRootKeys !== undefined) { + changedRootKeys.push(prevKeys[i]) + } + registry.prune(joinPath(parentPath, encodePathSegment(prevKeys[i]))) + } + } + } +} + +/** + * Diff an array: detect key field and dispatch to identity or index based. + * Separated from the main dispatcher for V8 monomorphic optimization. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @returns void + */ +function diffArray( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, +): void { + if (parentPath) { + registry.update(parentPath, next) + } + + if (prev.length !== next.length) { + registry.update(keysMetaPath(parentPath), next.length) + registry.update(joinPath(parentPath, 'length'), next.length) + } + + let meta = registry.getArrayMeta(parentPath) + if (meta) { + if (meta.entityMap.size === 0 && prev.length > 0) { + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], meta.keyField) + if (kv !== undefined) meta.entityMap.set(kv, prev[i]) + } + } + } else if (next.length > 0) { + const keyField = findKeyField(next[0]) + if (keyField) { + const entityMap = new Map() + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) entityMap.set(kv, prev[i]) + } + meta = { keyField, entityMap } + registry.setArrayMeta(parentPath, meta) + } + } + + if (meta) { + diffArrayByKey(prev, next, parentPath, registry, meta) + } else { + diffArrayByIndex(prev, next, parentPath, registry) + } +} + +/** + * Compare one surviving element's tracked column properties and fire + * column signals for any that changed. Non-object elements fire all + * remaining columns (can't shallow-compare — err toward firing). + * @param prevItem - Previous element value + * @param nextItem - Next element value + * @param parentPath - Path to the containing array + * @param registry - Signal registry to update + * @param cols - Tracked column property names for this array + * @param fired - Set of already-fired columns (dedup across elements) + * @returns void + */ +function bumpColumnsForElement( + prevItem: unknown, + nextItem: unknown, + parentPath: string, + registry: PathSignalRegistry, + cols: Set, + fired: Set, +): void { + if ( + prevItem !== null && + nextItem !== null && + typeof prevItem === 'object' && + typeof nextItem === 'object' + ) { + for (const prop of cols) { + if (fired.has(prop)) continue + if ( + (prevItem as Record)[prop] !== + (nextItem as Record)[prop] + ) { + registry.bumpColumn(parentPath, prop) + fired.add(prop) + } + } + } else { + // Non-object element changed — can't shallow-compare, fire everything + for (const prop of cols) { + if (!fired.has(prop)) { + registry.bumpColumn(parentPath, prop) + fired.add(prop) + } + } + } +} + +/** + * Fire every tracked column signal for an array. Used when elements + * can't be aligned between prev and next (unkeyed length changes). + * @param parentPath - Path to the array + * @param registry - Signal registry to update + * @param cols - Tracked column property names, if any + * @returns void + */ +function bumpAllColumns( + parentPath: string, + registry: PathSignalRegistry, + cols: Set | undefined, +): void { + if (!cols) return + for (const prop of cols) { + registry.bumpColumn(parentPath, prop) + } +} + +/** + * Walk prev and next state trees, updating path signals for changed values. + * Only visits subtrees that have tracked signals (registered paths). + * Exploits Immer's structural sharing: `prev === next` skips entire subtrees. + * + * Dispatches to monomorphic helpers (diffObject/diffArray) for V8 optimization. + * @param prev - Previous state value + * @param next - Next state value + * @param parentPath - Dot-separated path to this node in the state tree + * @param registry - Signal registry to update + * @returns void + */ +export function diffAndUpdateSignals( + prev: unknown, + next: unknown, + parentPath: string, + registry: PathSignalRegistry, +): void { + // Structural sharing: identical references mean nothing changed. + // The self-inequality test catches NaN (NaN !== NaN would otherwise + // fall through to the leaf branch and re-fire on every dispatch). + if (prev === next || (prev !== prev && next !== next)) return + + // Both plain objects: recurse into properties + if (isPlainObject(prev) && isPlainObject(next)) { + diffObject(prev, next, parentPath, registry) + return + } + + // Both arrays + if (Array.isArray(prev) && Array.isArray(next)) { + diffArray(prev, next, parentPath, registry) + return + } + + // Leaf value change: primitive, non-plain object (Date, Map, class + // instance — tracked by reference only), or type mismatch + // (object→primitive, array→object, ...) + if (parentPath) { + registry.update(parentPath, next) + } + + // If prev was an object/array, we did NOT recurse — any child signals + // registered under this path would go silently stale. + if (prev !== null && typeof prev === 'object') { + if (next === null || typeof next !== 'object') { + // object → primitive: the path itself is no longer an object; drop + // the whole subtree (fires child signals before removal) + registry.prune(parentPath) + } else { + // object → object we can't diff into (class instance replaced, + // array↔object type change): keep this path's just-updated signal + // but fire-and-drop the children + registry.pruneChildren(parentPath) + } + } +} + +/** + * Identity-based array diffing. Matches elements by their key field value. + * When an entity moves indices but content is unchanged (prev === next via + * structural sharing), the identity-based signal path stays the same and + * no signal updates are needed. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @param meta - Array identity metadata (key field and entity map) + * @returns void + */ +function diffArrayByKey( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, + meta: ArrayMeta, +): void { + const { keyField, entityMap: prevEntityMap } = meta + + const cols = registry.getTrackedColumns(parentPath) + const fired = cols && cols.size > 0 ? new Set() : null + const structs = registry.getTrackedStructures(parentPath) + + // Fast path: same-length arrays with no key shifts. + // Only touch changed elements. Update entityMap incrementally instead of rebuilding. + // Pure value changes — no structural bumps needed. + if (prev.length === next.length) { + let usedFastPath = true + for (let i = 0; i < next.length; i++) { + if (prev[i] === next[i]) continue // structural sharing: skip (most elements) + + // Different ref — verify same key at same index + const prevKv = getKeyValue(prev[i], keyField) + const nextKv = getKeyValue(next[i], keyField) + if (prevKv === undefined || nextKv === undefined || prevKv !== nextKv) { + usedFastPath = false + break + } + + // Same key, different content — diff and update entityMap entry + const identityPath = buildIdentityPath(parentPath, keyField, nextKv) + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(prev[i], next[i], identityPath, registry) + } + if (fired && cols) { + bumpColumnsForElement( + prev[i], + next[i], + parentPath, + registry, + cols, + fired, + ) + } + prevEntityMap.set(nextKv, next[i]) + } + if (usedFastPath) { + return + } + // Fast path failed partway — fall through to full identity diff below. + // Restore any partially-updated entityMap entries from prev array. + for (let i = 0; i < prev.length; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) prevEntityMap.set(kv, prev[i]) + } + } + + // Full identity-based diff: lengths differ or keys shifted positions. + // Forward scan: skip shared prefix where prev[i] === next[i]. + // This makes pure append/prepend-from-end nearly free. + const minLen = Math.min(prev.length, next.length) + let startIdx = 0 + while (startIdx < minLen && prev[startIdx] === next[startIdx]) { + startIdx++ + } + + // If we skipped the entire overlap and next is longer, it's a pure append. + // No Map needed — just update entityMap for new tail entries. + // Overlap is identical, so no value changes are possible — membership + // change is covered by the `append` structural signal alone. + if (startIdx === minLen && next.length >= prev.length) { + if (structs && next.length > prev.length) { + registry.bumpStructure(parentPath, 'append') + } + for (let i = startIdx; i < next.length; i++) { + const nextItem = next[i] + const kv = getKeyValue(nextItem, keyField) + if (kv !== undefined) { + prevEntityMap.set(kv, nextItem) + const identityPath = buildIdentityPath(parentPath, keyField, kv) + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(undefined, nextItem, identityPath, registry) + } + } + } + return + } + + // If we skipped the entire overlap and prev is longer, it's a pure truncation. + // Prune the removed tail entries from entityMap. + if (startIdx === minLen && prev.length > next.length) { + if (structs) { + registry.bumpStructure(parentPath, 'remove') + } + for (let i = startIdx; i < prev.length; i++) { + const prevItem = prev[i] + const kv = getKeyValue(prevItem, keyField) + if (kv !== undefined) { + prevEntityMap.delete(kv) + const identityPath = buildIdentityPath(parentPath, keyField, kv) + registry.prune(identityPath) + } + } + return + } + + // General case: build Map only from startIdx onward. + const nextEntityMap = new Map() + // Carry over skipped prefix entries from prevEntityMap + for (let i = 0; i < startIdx; i++) { + const kv = getKeyValue(next[i], keyField) + if (kv !== undefined) nextEntityMap.set(kv, next[i]) + } + + // Track every prev key seen in next so removed entities get pruned. + // Removals can happen at ANY relative length — a same-length + // remove+add (page swap, temp-id → server-id) still removes entities. + const seenPrevKeys = new Set() + // Mark skipped prefix keys as seen (they can't be removed) + for (let i = 0; i < startIdx; i++) { + const kv = getKeyValue(prev[i], keyField) + if (kv !== undefined) seenPrevKeys.add(kv) + } + + // Structural classification state (only maintained when tracked): + // - addedAny: any element whose key wasn't in prev + // - middleInsert: an added element appeared before a surviving element + // - reorder: survivors are not a subsequence of prev's order + // (checked with a forward-only pointer into prev) + // - survivorCount: next elements (from startIdx) whose key existed in prev + // - sawUnkeyed: element without a key — can't classify, fire everything + let addedAny = false + let middleInsert = false + let reorder = false + let survivorCount = 0 + let sawUnkeyed = false + let pi = startIdx + + for (let i = startIdx; i < next.length; i++) { + const nextItem = next[i] + const kv = getKeyValue(nextItem, keyField) + + if (kv === undefined) { + sawUnkeyed = true + const childPath = joinPath(parentPath, i) + const prevItem = i < prev.length ? prev[i] : undefined + if (prevItem !== nextItem) { + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prevItem, nextItem, childPath, registry) + } + if (fired && cols) { + bumpColumnsForElement( + prevItem, + nextItem, + parentPath, + registry, + cols, + fired, + ) + } + } + continue + } + + nextEntityMap.set(kv, nextItem) + const prevItem = prevEntityMap.get(kv) + const isSurvivor = prevItem !== undefined + + if (isSurvivor) { + survivorCount++ + if (addedAny) middleInsert = true + if (structs && !reorder) { + // Subsequence check: survivors must appear in prev in the same + // relative order. Advance pointer to this key; if we run off the + // end, order changed. + while (pi < prev.length && getKeyValue(prev[pi], keyField) !== kv) { + pi++ + } + if (pi >= prev.length) { + reorder = true + } else { + pi++ + } + } + } else { + addedAny = true + } + + if (prevItem === nextItem) { + seenPrevKeys.add(kv) + continue + } + + seenPrevKeys.add(kv) + + const identityPath = buildIdentityPath(parentPath, keyField, kv) + + if (isSurvivor) { + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(prevItem, nextItem, identityPath, registry) + } + if (fired && cols) { + bumpColumnsForElement( + prevItem, + nextItem, + parentPath, + registry, + cols, + fired, + ) + } + } else { + if (registry.hasPrefix(identityPath)) { + diffAndUpdateSignals(undefined, nextItem, identityPath, registry) + } + } + } + + for (const [kv] of prevEntityMap) { + if (!seenPrevKeys.has(kv)) { + const identityPath = buildIdentityPath(parentPath, keyField, kv) + registry.prune(identityPath) + } + } + + meta.entityMap = nextEntityMap + + if (structs) { + // Removals: prev suffix elements not matched by a surviving key. + // Over-counts in rare duplicate-key cases — errs toward firing. + const removedAny = prev.length - startIdx > survivorCount + if (sawUnkeyed) { + registry.bumpStructure(parentPath, 'append') + registry.bumpStructure(parentPath, 'insertOrReorder') + registry.bumpStructure(parentPath, 'remove') + } else { + if (addedAny) registry.bumpStructure(parentPath, 'append') + if (middleInsert || reorder) { + registry.bumpStructure(parentPath, 'insertOrReorder') + } + if (removedAny) registry.bumpStructure(parentPath, 'remove') + } + } +} + +/** + * Index-based array diffing (original algorithm). + * Used for primitive arrays or arrays without a detectable key field. + * @param prev - Previous array state + * @param next - Next array state + * @param parentPath - Dot-separated path to this array in the state tree + * @param registry - Signal registry to update + * @returns void + */ +function diffArrayByIndex( + prev: unknown[], + next: unknown[], + parentPath: string, + registry: PathSignalRegistry, +): void { + const cols = registry.getTrackedColumns(parentPath) + const fired = cols && cols.size > 0 ? new Set() : null + + if (prev.length !== next.length) { + // Unkeyed length change: elements can't be aligned between prev and + // next, so we can't classify the change — fire all structural kinds + // and all columns (err toward firing). + const structs = registry.getTrackedStructures(parentPath) + if (structs) { + registry.bumpStructure(parentPath, 'append') + registry.bumpStructure(parentPath, 'insertOrReorder') + registry.bumpStructure(parentPath, 'remove') + } + bumpAllColumns(parentPath, registry, cols) + } + + const minLen = Math.min(prev.length, next.length) + for (let i = 0; i < minLen; i++) { + if (prev[i] !== next[i]) { + const childPath = joinPath(parentPath, i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(prev[i], next[i], childPath, registry) + } + // Same-length: aligned compare per changed index covers value + // changes AND swaps (both indexes show ref mismatches). + if (fired && cols && prev.length === next.length) { + bumpColumnsForElement( + prev[i], + next[i], + parentPath, + registry, + cols, + fired, + ) + } + } + } + for (let i = minLen; i < next.length; i++) { + const childPath = joinPath(parentPath, i) + if (registry.hasPrefix(childPath)) { + diffAndUpdateSignals(undefined, next[i], childPath, registry) + } + } + for (let i = next.length; i < prev.length; i++) { + registry.prune(joinPath(parentPath, i)) + } +} + +const NO_CHANGED_KEYS: string[] = [] + +/** + * Wrapper that batches all signal updates into a single propagation pass. + * + * Returns the raw root keys whose values changed (added / removed / + * ref-changed) so the SignalProvider can wake coarse-tier subscribers, + * or `null` when the root wasn't a diffable pair of plain objects (the + * caller must treat every coarse subscriber as a candidate). + * @param prev - Previous state + * @param next - Next state + * @param registry - Signal registry to update + * @param engine - Signal engine for batching + * @returns Changed root keys, or null if the root couldn't be diffed + */ +export function reconcileState( + prev: unknown, + next: unknown, + registry: PathSignalRegistry, + engine: SignalEngine, +): string[] | null { + if (prev === next) return NO_CHANGED_KEYS + + if (isPlainObject(prev) && isPlainObject(next)) { + const changedRootKeys: string[] = [] + engine.batch(() => { + diffObject(prev, next, '', registry, changedRootKeys) + }) + return changedRootKeys + } + + engine.batch(() => { + diffAndUpdateSignals(prev, next, '', registry) + }) + return null +} diff --git a/src/signals/engine.ts b/src/signals/engine.ts new file mode 100644 index 000000000..46d8bba7c --- /dev/null +++ b/src/signals/engine.ts @@ -0,0 +1,72 @@ +import { + signal as createSignal, + computed as createComputed, + effect as createEffect, + effectScope as createEffectScope, + startBatch, + endBatch, +} from './reactiveSystem' +import type { SignalOwner } from './reactiveSystem' +import type { + SignalEngine, + ReactiveSignal, + ReactiveComputed, + SignalScope, +} from './types' + +/** + * The engine backing `pathSignalRegistry` and `reconcileState`, built on + * the vendored reactive layer in `./reactiveSystem`. + * + * This is internal-only and is not part of the public API. It exists as + * an injection seam: the reconcile benchmark substitutes a + * no-propagation stub to measure pure diff cost, and unit tests use + * `createScope` to contain effects. Application code always gets this + * implementation. + * + * `signal` and `computed` return the graph nodes directly. The nodes + * implement `get`/`set` themselves, so unlike the previous wrapper over + * `alien-signals`' bound-function API there is no per-signal wrapper + * object or closure pair to allocate — which matters because one node + * exists per tracked state path. + */ +export const alienEngine: SignalEngine = { + signal(value: T, owner?: SignalOwner, path?: string): ReactiveSignal { + return createSignal(value, owner, path) + }, + + computed(fn: () => T): ReactiveComputed { + return createComputed(fn) + }, + + effect(fn: () => void): () => void { + return createEffect(fn) + }, + + batch(fn: () => void): void { + startBatch() + try { + fn() + } finally { + endBatch() + } + }, + + createScope(): SignalScope { + let dispose: (() => void) | undefined + + return { + run(fn: () => T): T { + let result: T + dispose = createEffectScope(() => { + result = fn() + }) + return result! + }, + stop(): void { + dispose?.() + dispose = undefined + }, + } + }, +} diff --git a/src/signals/index.ts b/src/signals/index.ts new file mode 100644 index 000000000..847ce26ed --- /dev/null +++ b/src/signals/index.ts @@ -0,0 +1,20 @@ +export { SignalProvider } from './SignalProvider' +export type { SignalProviderProps } from './SignalProvider' + +export { + createSignalSelectorHook, + useSignalSelector, +} from './useSignalSelector' +export type { UseSignalSelector } from './useSignalSelector' + +export { createSignalContextHook, useSignalContext } from './context' +export type { SignalContextValue } from './context' + +export type { PathKey, ReactiveSignal, ReactiveComputed } from './types' + +export { createPathSignalRegistry } from './pathSignalRegistry' +export type { PathSignalRegistry } from './pathSignalRegistry' + +export { createTrackingProxy, unwrap } from './trackingProxy' + +export { diffAndUpdateSignals, reconcileState } from './diff' diff --git a/src/signals/mutationError.ts b/src/signals/mutationError.ts new file mode 100644 index 000000000..a77e00753 --- /dev/null +++ b/src/signals/mutationError.ts @@ -0,0 +1,33 @@ +/** + * Throw a descriptive error for a selector attempting to write to Redux + * state through one of the read-only proxies (probe guard, deep tracking + * proxy, or array scan recorder). + * + * Every write trap throws instead of returning `false`: `return false` + * only throws in strict-mode callers (with the engine's cryptic + * "'set' on proxy: trap returned falsish"), and silently no-ops in + * sloppy-mode code. Mutating state from a selector is always a bug, so + * it should fail loudly with a message that names the property. + * @param op - The kind of write that was attempted + * @param path - Dot-separated state path, or a bare property name when + * the full path is unknown + * @returns Never returns — always throws a `TypeError` + */ +export function throwStateMutationError( + op: 'set' | 'delete' | 'defineProperty' | 'setPrototypeOf', + path: string, +): never { + const verb = + op === 'set' + ? `assign to property '${path}' of` + : op === 'delete' + ? `delete property '${path}' from` + : op === 'defineProperty' + ? `define property '${path}' on` + : 'change the prototype of' + throw new TypeError( + `[react-redux] A selector attempted to ${verb} the Redux state. ` + + `Selectors must not mutate state — treat state as read-only and ` + + `return new values instead.`, + ) +} diff --git a/src/signals/pathSignalRegistry.ts b/src/signals/pathSignalRegistry.ts new file mode 100644 index 000000000..b9f97c499 --- /dev/null +++ b/src/signals/pathSignalRegistry.ts @@ -0,0 +1,653 @@ +import { encodePathSegment } from './arrayKeys' +import type { ArrayMeta } from './arrayKeys' +import { createSegmentIndex } from './coarseSegments' +import type { SegmentIndex } from './coarseSegments' +import type { LeafObjectTracker, ProxyCache } from './trackingProxy' +import type { PathKey, ReactiveSignal, SignalEngine } from './types' + +/** + * Kinds of structural array changes that scans can depend on selectively. + * - `append`: elements added after all surviving elements + * - `insertOrReorder`: elements inserted before surviving elements, or + * surviving elements changed relative order + * - `remove`: elements removed (relative order of survivors preserved) + */ +export type StructureKind = 'append' | 'insertOrReorder' | 'remove' + +export interface RegistryStats { + signals: number + prefixCounts: number + prefixOnlyPaths: number + childIndex: number + arrayMetas: number + columnsByArray: number + structuresByArray: number + segmentSubs: number +} + +export interface PathSignalRegistry { + /** Get or create a signal for a path. Object/array values use version counters. */ + getOrCreate(pathKey: PathKey, currentValue: unknown): ReactiveSignal + + /** Register a path in the prefix index without creating a signal. + * Used for intermediate object traversals where we need hasPrefix() + * to work for diff, but don't need a signal (no reactive dependency). */ + ensurePrefix(pathKey: PathKey): void + + /** Update a signal's value (called during diff). */ + update(pathKey: PathKey, newValue: unknown): void + + /** Remove signal for a path and all child paths. + * Uses a parent→children index for O(subtree) instead of O(total signals). */ + prune(pathKey: PathKey): void + + /** Remove signals for all child paths, keeping the path's own signal. + * Used when a subtree is replaced by a value the diff can't recurse + * into (e.g., a class instance) — child reads fire once instead of + * going silently stale. */ + pruneChildren(pathKey: PathKey): void + + /** Drop all bookkeeping for a path whose signal just lost its last + * subscriber. Called by the signal itself, from inside the reactive + * system's `unlink`, when the last watching component unmounts. + * + * Distinct from `prune()` in two ways that matter: + * + * - No sentinel write and no signal mutation of any kind. It runs + * mid-`unlink`, so touching the graph would reenter it. `prune()` + * fires the signal because state actually changed; here the state + * is untouched and there is by definition nobody left to notify. + * - No subtree walk. Each descendant signal gets its own `unwatched` + * callback, so a subtree unmounting releases bottom-up on its own. + * + * `node` is checked against the currently registered signal for the + * path. `prune()` can delete a signal and a later `getOrCreate` can + * install a fresh one at the same path before the old node's links + * are torn down; without the identity check that late callback would + * evict the live replacement. */ + release(pathKey: PathKey, node: ReactiveSignal): void + + /** Number of active signals. */ + size(): number + + /** Check if a signal exists for a path (without creating one). */ + has(pathKey: PathKey): boolean + + /** Check if any tracked signal exists at or below this path prefix. */ + hasPrefix(prefix: string): boolean + + /** Return all registered signal paths (for testing/debugging). */ + debugPaths(): string[] + + /** Entry counts for every internal structure (for testing/debugging). */ + debugStats(): RegistryStats + + /** Coarse tier: inverted index of top-level state keys → deferred + * subscribers. Driven by SignalProvider with the changed root keys + * reconcileState reports each dispatch. */ + segmentIndex: SegmentIndex + + /** Proxy cache for reusing proxies across evaluations (keyed by object identity). */ + proxyCache: ProxyCache + + /** Encoded path keys for top-level state properties. Root proxies are + * created fresh per evaluation, so they share this instead of each + * building its own. */ + rootPathKeyCache: Map + + /** Incremented each time a root tracking proxy is created, i.e. once + * per selector evaluation. Proxies use it to scope their last-read + * memo to the current evaluation. */ + evalEpoch: number + + /** Holder for the leaf tracker of the evaluation currently running. + * Proxies read this at trap time instead of closing over a tracker, + * so cached proxies record into whichever evaluation is active. */ + leafTrackerHolder: { current: LeafObjectTracker | undefined } + + /** Get array metadata for identity-based tracking at a given path. */ + getArrayMeta(arrayPath: string): ArrayMeta | undefined + + /** Set/update array metadata for identity-based tracking. */ + setArrayMeta(arrayPath: string, meta: ArrayMeta): void + + /** Register a column signal for an array: a dependency on property `prop` + * of ANY element of the array at `arrayPath`. Fired by diff when any + * element's value for that property changes (or membership changes). */ + trackColumn(arrayPath: string, prop: string): ReactiveSignal + + /** Get the set of tracked column properties for an array path. */ + getTrackedColumns(arrayPath: string): Set | undefined + + /** Fire the column signal for (arrayPath, prop), if one exists. */ + bumpColumn(arrayPath: string, prop: string): void + + /** Register a structural signal for an array: a dependency on a specific + * kind of membership/order change (append, insertOrReorder, remove). + * Fired selectively by diff based on how the array actually changed. */ + trackStructure( + arrayPath: string, + kind: StructureKind, + ): ReactiveSignal + + /** Get the set of tracked structure kinds for an array path. */ + getTrackedStructures(arrayPath: string): Set | undefined + + /** Fire the structural signal for (arrayPath, kind), if one exists. */ + bumpStructure(arrayPath: string, kind: StructureKind): void +} + +/** + * Build the path key for a column signal. + * Uses `{*}` as the element segment — it can never collide with real + * object keys or identity paths (`{keyField:value}`). + * @param arrayPath - Path to the array in the state tree + * @param prop - Element property name being tracked + * @returns The column signal path key + */ +function buildColumnPath(arrayPath: string, prop: string): string { + // prop comes from user state (a scanned element property name), so it + // is encoded like any other path segment. Both trackColumn (proxy + // side) and bumpColumn (diff side) route through here, so the two + // sides always agree. + return arrayPath + '.{*}.' + encodePathSegment(prop) +} + +/** + * Build the path key for a structural signal. + * Uses `@@` segments (like `@@keys`) that can't collide with real keys. + * @param arrayPath - Path to the array in the state tree + * @param kind - The structural change kind + * @returns The structural signal path key + */ +function buildStructurePath(arrayPath: string, kind: StructureKind): string { + return arrayPath + '.@@' + kind +} + +// Object/array/column/structure signals hold version counters, not +// values. Bump forces every subscribed computed to re-evaluate. The +// non-number fallback resets a signal whose last value was a PRUNED +// sentinel or a real leaf value before the path's shape changed. +function bumpVersion(sig: ReactiveSignal): void { + const current = sig.get() + sig.set(typeof current === 'number' ? current + 1 : 0) +} + +function isObjectOrArray(v: unknown): v is object { + return v !== null && typeof v === 'object' +} + +// Unique value written into a signal as it is pruned, guaranteed to be +// `!==` its current value so the signal always fires. Setting the same +// primitive back (the old behavior) silently no-ops for strings/booleans +// under alien-signals' `!==` equality, leaving dependent computeds +// permanently stale after an entity removal. +const PRUNED = Symbol('pruned') + +// Increment prefix counters for all ancestor paths of a given pathKey. +// e.g., "a.b.c" increments counters for "a.b.c", "a.b", "a" +function incrementPrefixes( + prefixCounts: Map, + pathKey: string, +): void { + prefixCounts.set(pathKey, (prefixCounts.get(pathKey) || 0) + 1) + let idx = pathKey.lastIndexOf('.') + while (idx !== -1) { + const prefix = pathKey.substring(0, idx) + prefixCounts.set(prefix, (prefixCounts.get(prefix) || 0) + 1) + idx = prefix.lastIndexOf('.') + } +} + +function decrementPrefixes( + prefixCounts: Map, + pathKey: string, +): void { + let count = prefixCounts.get(pathKey) + if (count !== undefined) { + if (count <= 1) prefixCounts.delete(pathKey) + else prefixCounts.set(pathKey, count - 1) + } + let idx = pathKey.lastIndexOf('.') + while (idx !== -1) { + const prefix = pathKey.substring(0, idx) + count = prefixCounts.get(prefix) + if (count !== undefined) { + if (count <= 1) prefixCounts.delete(prefix) + else prefixCounts.set(prefix, count - 1) + } + idx = prefix.lastIndexOf('.') + } +} + +export function createPathSignalRegistry( + engine: SignalEngine, +): PathSignalRegistry { + const signals = new Map>() + // Prefix counter map: for each path prefix, how many tracked signals + // exist at or below that prefix. Enables O(1) hasPrefix lookups. + const prefixCounts = new Map() + // Paths that are prefix-only registered (no signal created yet). + // Tracked so ensurePrefix is idempotent and getOrCreate can skip + // re-incrementing prefixes if ensurePrefix was called first. + const prefixOnlyPaths = new Set() + // Parent→children index: maps each path to its direct children. + // Enables O(subtree) prune instead of O(total signals) linear scan. + const childIndex = new Map>() + // Proxy cache: keyed by target object identity. + // Reuses proxies for unchanged Immer subtrees across state snapshots. + const proxyWeakMap: ProxyCache = new WeakMap() + // Per-array metadata for identity-based tracking. + // Maps array path → ArrayMeta (keyField + entityMap). + const arrayMetas = new Map() + // Tracked column properties per array path. + // Lets diffArray know which element properties to shallow-compare. + const columnsByArray = new Map>() + // Tracked structural change kinds per array path. + // Lets diffArray know whether structural classification is needed. + const structuresByArray = new Map>() + + // Register a path in the parent→children index. + // Only links to immediate parent: "a.b.c" → childIndex["a.b"].add("a.b.c") + // Ancestors are already linked by their own registrations: + // when "a.b" was registered, childIndex["a"].add("a.b") was called. + function addToChildIndex(pathKey: string): void { + const idx = pathKey.lastIndexOf('.') + if (idx === -1) return // root-level path, no parent + const parent = pathKey.substring(0, idx) + let children = childIndex.get(parent) + if (!children) { + children = new Set() + childIndex.set(parent, children) + } + children.add(pathKey) + } + + // Remove a path from its parent's child set, dropping the parent's + // entry entirely once it has no children left. + function detachFromParent(pathKey: string): void { + const idx = pathKey.lastIndexOf('.') + if (idx === -1) return + const parent = pathKey.substring(0, idx) + const siblings = childIndex.get(parent) + if (siblings === undefined) return + siblings.delete(pathKey) + if (siblings.size === 0) { + childIndex.delete(parent) + } + } + + // Paths whose signal has been released but whose prefix accounting and + // structural unlinking have not been applied yet. Draining is deferred + // so that a subtree unmounting - which fires one `release` per node - + // pays for each shared ancestor once instead of once per node. + const pendingReleases = new Set() + + function depthOf(path: string): number { + let depth = 0 + for (let i = 0; i < path.length; i++) { + if (path.charCodeAt(i) === 46 /* '.' */) depth++ + } + return depth + } + + /** + * Apply the prefix accounting and structural unlinking owed by every + * released path, in one pass. + * + * Doing this per-release is quadratic in two separate ways. A list of + * 100 items unmounting under `todos` walks `todos` and the root 100 + * times over, and each dead prefix-only ancestor found on the way up + * kicks off its own full ancestor walk to decrement counts. Both + * collapse if the released paths are processed together. + * + * The pass works deepest-first over every path touched. Each path + * carries a count of how many decrements it is owed, merged from all + * of its released descendants, and passes that total to its parent + * once applied. Depth ordering guarantees a path is only reached + * after every one of its children has contributed, so each unique + * ancestor is visited exactly once regardless of how many releases + * pointed at it. + * + * Two kinds of ancestor get structurally removed here. Prefix-only + * paths, registered by `ensurePrefix`, never get a signal, so + * `unwatched` can never fire for them; without this they would keep + * `hasPrefix` true forever and the diff would keep descending into an + * empty subtree, which is the exact cost this whole mechanism exists + * to remove. A prefix-only path contributes 1 to its own count plus 1 + * per signal at or below it, so a remaining count of 1 or less means + * only that self-registration is left and the path is dead; clearing + * it adds one more decrement to everything above. Link-only paths are + * ones whose own signal was released while descendants were still + * alive, so the release deliberately left them attached; once the + * last child goes they can finally be unlinked. + * + * A path still holding children, or still holding a live signal, is + * left alone. No early exit is needed for that: a live path keeps its + * entry in its parent's child set, so every ancestor above it fails + * the `childIndex.has` test on its own. + * + * Touches only `prefixCounts`, `prefixOnlyPaths` and `childIndex` - + * never a signal - so it is safe to run from anywhere, including + * mid-`unlink`. + * + * @returns Nothing. + */ + function flushReleases(): void { + // How many prefix-count decrements each touched path still owes. + const owed = new Map() + // Touched paths bucketed by depth, so the pass can go deepest-first + // without sorting. + const byDepth: (string[] | undefined)[] = [] + + function addOwed(path: string, count: number): void { + const prev = owed.get(path) + if (prev !== undefined) { + owed.set(path, prev + count) + return + } + owed.set(path, count) + const depth = depthOf(path) + const bucket = byDepth[depth] + if (bucket === undefined) byDepth[depth] = [path] + else bucket.push(path) + } + + for (const path of pendingReleases) addOwed(path, 1) + // Cleared up front: the pass only reads `signals`, and a released + // path was removed from it synchronously. + pendingReleases.clear() + + for (let depth = byDepth.length - 1; depth >= 0; depth--) { + const bucket = byDepth[depth] + if (bucket === undefined) continue + // Parents are strictly shallower, so entries appended below always + // land in a bucket this loop has not reached yet. + for (const path of bucket) { + let count = owed.get(path)! + + const current = prefixCounts.get(path) + if (current !== undefined) { + if (current <= count) prefixCounts.delete(path) + else prefixCounts.set(path, current - count) + } + + if (!childIndex.has(path)) { + if (prefixOnlyPaths.has(path)) { + if ((prefixCounts.get(path) ?? 0) <= 1) { + prefixOnlyPaths.delete(path) + prefixCounts.delete(path) + detachFromParent(path) + count += 1 + } + } else if (!signals.has(path)) { + // Either a path just released, or a link-only node whose + // last child has now gone. A path resurrected by + // `getOrCreate` before this flush ran is live again and + // correctly falls through untouched. + detachFromParent(path) + } + } + + const dot = path.lastIndexOf('.') + if (dot !== -1) addOwed(path.substring(0, dot), count) + } + } + } + + const registry: PathSignalRegistry = { + getOrCreate( + pathKey: PathKey, + currentValue: unknown, + ): ReactiveSignal { + let sig = signals.get(pathKey) + if (!sig) { + if (pendingReleases.size !== 0) flushReleases() + const initialValue = isObjectOrArray(currentValue) ? 0 : currentValue + // Passing the registry as owner is what makes the signal call + // back into `release` when its last subscriber goes away. + sig = engine.signal(initialValue, registry, pathKey) + signals.set(pathKey, sig) + addToChildIndex(pathKey) + // If ensurePrefix was called first, prefixes are already counted + if (prefixOnlyPaths.has(pathKey)) { + prefixOnlyPaths.delete(pathKey) + } else { + incrementPrefixes(prefixCounts, pathKey) + } + } + return sig + }, + + ensurePrefix(pathKey: PathKey): void { + // Already has a signal or already prefix-registered — nothing to do + if (signals.has(pathKey) || prefixOnlyPaths.has(pathKey)) return + // Flush only removes entries from these structures, so the checks + // above cannot flip from false to true across it. + if (pendingReleases.size !== 0) flushReleases() + prefixOnlyPaths.add(pathKey) + addToChildIndex(pathKey) + incrementPrefixes(prefixCounts, pathKey) + }, + + update(pathKey: PathKey, newValue: unknown): void { + const sig = signals.get(pathKey) + if (!sig) return + + if (isObjectOrArray(newValue)) { + bumpVersion(sig) + } else { + sig.set(newValue) + } + }, + + prune(pathKey: PathKey): void { + if (pendingReleases.size !== 0) flushReleases() + // Recursively remove this path and all descendants using the child index. + // O(subtree size) instead of O(total signals). + const stack: string[] = [pathKey] + while (stack.length > 0) { + const key = stack.pop()! + // Drop column/structure/identity tracking for any array at or below the pruned path + columnsByArray.delete(key) + structuresByArray.delete(key) + arrayMetas.delete(key) + // Push children onto stack before deleting + const children = childIndex.get(key) + if (children) { + for (const child of children) { + stack.push(child) + } + childIndex.delete(key) + } + // Remove from signals or prefix-only, and decrement prefix counts + if (signals.has(key)) { + const sig = signals.get(key)! + // Fire the signal before removing it. This ensures any computed + // that depended on this path re-evaluates with the new state. + // Prune runs inside engine.batch() via reconcileState, so the + // computed won't re-run until the batch completes. The PRUNED + // sentinel guarantees the write is `!==` the current value — + // dependents never read the value itself, they re-run the + // selector against real state. + sig.set(PRUNED) + signals.delete(key) + decrementPrefixes(prefixCounts, key) + } else if (prefixOnlyPaths.has(key)) { + prefixOnlyPaths.delete(key) + decrementPrefixes(prefixCounts, key) + } + } + // Remove this path from its parent's child set + const dotIdx = pathKey.lastIndexOf('.') + if (dotIdx !== -1) { + const parent = pathKey.substring(0, dotIdx) + const parentChildren = childIndex.get(parent) + if (parentChildren) { + parentChildren.delete(pathKey) + if (parentChildren.size === 0) { + childIndex.delete(parent) + } + } + } + }, + + release(pathKey: PathKey, node: ReactiveSignal): void { + // Identity guard: a fresh signal may already have replaced this + // one at the same path (see the interface docs). + if (signals.get(pathKey) !== node) return + + signals.delete(pathKey) + columnsByArray.delete(pathKey) + structuresByArray.delete(pathKey) + arrayMetas.delete(pathKey) + + // A column signal lives at `.{*}.`, so releasing + // it has to drop `prop` from the array's tracked column set as + // well, or diffArray keeps shallow-comparing a column nobody + // reads. Same shape of problem for structure signals. + const columnMark = pathKey.lastIndexOf('.{*}.') + if (columnMark !== -1) { + const arrayPath = pathKey.substring(0, columnMark) + const cols = columnsByArray.get(arrayPath) + if (cols !== undefined) { + // The set holds raw props while the path holds encoded ones, + // and there is no decoder. Encoding each candidate is fine — + // a tracked column set is a handful of entries, and encoding + // returns the input unchanged for the common case. + const encodedProp = pathKey.substring(columnMark + 5) + for (const prop of cols) { + if (encodePathSegment(prop) === encodedProp) { + cols.delete(prop) + break + } + } + if (cols.size === 0) columnsByArray.delete(arrayPath) + } + } else { + const structureMark = pathKey.lastIndexOf('.@@') + if (structureMark !== -1) { + const arrayPath = pathKey.substring(0, structureMark) + const kinds = structuresByArray.get(arrayPath) + if (kinds !== undefined) { + kinds.delete(pathKey.substring(structureMark + 3) as StructureKind) + if (kinds.size === 0) structuresByArray.delete(arrayPath) + } + } + } + + // Prefix accounting and structural unlinking are deferred to the + // next `flushReleases`, which merges this path's ancestor walk + // with every other release queued alongside it. Until then + // `prefixCounts` reads high, so `hasPrefix` can say yes for a + // subtree that is already dead - a missed optimization for the + // rest of the current teardown, never a wrong answer, and every + // reader of that state flushes first. + pendingReleases.add(pathKey) + }, + + pruneChildren(pathKey: PathKey): void { + if (pendingReleases.size !== 0) flushReleases() + const children = childIndex.get(pathKey) + if (!children) return + // prune() mutates the parent's child set — iterate over a copy + for (const child of Array.from(children)) { + registry.prune(child) + } + }, + + size(): number { + return signals.size + }, + + has(pathKey: PathKey): boolean { + return signals.has(pathKey) + }, + + hasPrefix(prefix: string): boolean { + if (pendingReleases.size !== 0) flushReleases() + return (prefixCounts.get(prefix) || 0) > 0 + }, + + debugPaths(): string[] { + return Array.from(signals.keys()).sort() + }, + + debugStats(): RegistryStats { + if (pendingReleases.size !== 0) flushReleases() + return { + signals: signals.size, + prefixCounts: prefixCounts.size, + prefixOnlyPaths: prefixOnlyPaths.size, + childIndex: childIndex.size, + arrayMetas: arrayMetas.size, + columnsByArray: columnsByArray.size, + structuresByArray: structuresByArray.size, + segmentSubs: registry.segmentIndex.size(), + } + }, + + segmentIndex: createSegmentIndex(), + + proxyCache: proxyWeakMap, + + rootPathKeyCache: new Map(), + evalEpoch: 0, + + leafTrackerHolder: { current: undefined }, + + getArrayMeta(arrayPath: string): ArrayMeta | undefined { + return arrayMetas.get(arrayPath) + }, + + setArrayMeta(arrayPath: string, meta: ArrayMeta): void { + arrayMetas.set(arrayPath, meta) + }, + + trackColumn(arrayPath: string, prop: string): ReactiveSignal { + let cols = columnsByArray.get(arrayPath) + if (!cols) { + cols = new Set() + columnsByArray.set(arrayPath, cols) + } + cols.add(prop) + // Register the intermediate `{*}` segment so prune(arrayPath) + // reaches column signals through the child index. + registry.ensurePrefix(arrayPath + '.{*}') + return registry.getOrCreate(buildColumnPath(arrayPath, prop), 0) + }, + + getTrackedColumns(arrayPath: string): Set | undefined { + return columnsByArray.get(arrayPath) + }, + + bumpColumn(arrayPath: string, prop: string): void { + const sig = signals.get(buildColumnPath(arrayPath, prop)) + if (sig) bumpVersion(sig) + }, + + trackStructure( + arrayPath: string, + kind: StructureKind, + ): ReactiveSignal { + let kinds = structuresByArray.get(arrayPath) + if (!kinds) { + kinds = new Set() + structuresByArray.set(arrayPath, kinds) + } + kinds.add(kind) + return registry.getOrCreate(buildStructurePath(arrayPath, kind), 0) + }, + + getTrackedStructures(arrayPath: string): Set | undefined { + return structuresByArray.get(arrayPath) + }, + + bumpStructure(arrayPath: string, kind: StructureKind): void { + const sig = signals.get(buildStructurePath(arrayPath, kind)) + if (sig) bumpVersion(sig) + }, + } + + return registry +} diff --git a/src/signals/reactiveSystem.ts b/src/signals/reactiveSystem.ts new file mode 100644 index 000000000..b088577b9 --- /dev/null +++ b/src/signals/reactiveSystem.ts @@ -0,0 +1,483 @@ +/** + * Vendored effect layer for alien-signals. + * + * Adapted from `alien-signals/esm/index.mjs` (v3.2.1). The graph + * propagation itself is NOT vendored — it stays in the upstream + * `alien-signals/system` export, which is a first-class public entry + * point in the package's `exports` map with its own type declarations. + * + * Why vendor at all: `createReactiveSystem` takes an `unwatched(node)` + * handler that fires the moment a node loses its last subscriber. That + * is exactly the fact `pathSignalRegistry` needs in order to release a + * path signal when the last component watching it unmounts. Upstream's + * `index.mjs` installs a handler whose plain-signal branch is + * deliberately empty, and it is installed once at module scope, so the + * information is unreachable through the published API at any price. + * + * Differences from upstream, all deliberate: + * + * - `trigger`, `isSignal`, `isComputed`, `isEffect`, `isEffectScope`, + * `getActiveSub` and `getBatchDepth` are dropped. Nothing in this + * package calls them. + * - `effectScope` is kept. `src/` never calls it, but + * `SignalEngine.createScope()` is used by roughly twenty test call + * sites and by the reconcile benchmark engine, so removing it would + * mean rewriting those rather than saving meaningful code. + * - `effectOper` and `effectScopeOper` collapse into one `stopEffect`. + * Upstream splits them only because the effect variant additionally + * runs a cleanup; a scope simply has no cleanup to run. + * - Nodes are classes that expose `get`/`set` directly instead of + * `Function.prototype.bind`-ed operators. The registry can then store + * the node itself, which makes the `unwatched` -> `release` identity + * check free and removes a wrapper allocation per tracked path. + * - `unwatched`'s plain-signal branch calls back into the owning + * registry (see `SignalOwner`). + * + * `test/signals/reactiveSystemGuard.spec.ts` asserts the shape of the + * upstream `system` export so a breaking change there fails loudly + * rather than silently. + */ +import { createReactiveSystem } from 'alien-signals/system' +import type { Link, ReactiveNode } from 'alien-signals/system' +import type { ReactiveComputed, ReactiveSignal } from './types' + +// `ReactiveFlags` is declared as a `const enum` upstream, which cannot +// be imported under `isolatedModules`. These mirror it exactly, plus +// `HasChildEffect`, which upstream's index.mjs also defines locally. +const Mutable = 1 +const Watching = 2 +const RecursedCheck = 4 +const Recursed = 8 +const Dirty = 16 +const Pending = 32 +const HasChildEffect = 64 + +/** + * Implemented by `pathSignalRegistry`. A signal node created with an + * owner calls `release` when it loses its last subscriber. + */ +export interface SignalOwner { + /** + * Drop the registry bookkeeping for `pathKey`, but only if `node` is + * still the signal currently registered at that path. + * + * The identity check matters: `prune()` can delete a signal and a + * later `getOrCreate` can install a fresh one at the same path before + * the old node's links are torn down. Releasing on path alone would + * then evict a live signal. + */ + release(pathKey: string, node: ReactiveSignal): void +} + +let cycle = 0 +let runDepth = 0 +let batchDepth = 0 +let notifyIndex = 0 +let queuedLength = 0 +let activeSub: ReactiveNode | undefined +const queued: (EffectNode | undefined)[] = [] + +function isComputedNode(node: ReactiveNode): node is ComputedNode { + return 'getter' in node +} + +function isSignalNode(node: ReactiveNode): node is SignalNode { + return 'currentValue' in node +} + +// Kept as a single object rather than destructured: bundlers cannot drop a +// pure call whose result is destructured at the top level, but they can drop +// `system` when nothing that reads it survives tree-shaking. That lets an app +// importing only the stock hooks from the main entry point shed the whole +// reactive system. +const system = /* @__PURE__ */ createReactiveSystem({ + update(node: ReactiveNode): boolean { + if (isComputedNode(node)) { + return updateComputed(node) + } + if (isSignalNode(node)) { + return updateSignal(node) + } + node.flags = Mutable + return true + }, + + notify(node: ReactiveNode): void { + let effect = node as EffectNode + let insertIndex = queuedLength + let firstInsertedIndex = insertIndex + for (;;) { + queued[insertIndex++] = effect + effect.flags &= ~Watching + const next = effect.subs?.sub as EffectNode | undefined + if (next === undefined || !(next.flags & Watching)) { + break + } + effect = next + } + queuedLength = insertIndex + while (firstInsertedIndex < --insertIndex) { + const left = queued[firstInsertedIndex] + queued[firstInsertedIndex++] = queued[insertIndex] + queued[insertIndex] = left + } + }, + + unwatched(node: ReactiveNode): void { + if (isComputedNode(node)) { + if (node.depsTail !== undefined) { + node.flags = Mutable | Dirty + disposeAllDepsInReverse(node) + } + return + } + if (isSignalNode(node)) { + // The one behavioral addition over upstream, whose branch here + // is empty. Runs from inside `unlink`, which itself runs during + // effect re-runs and disposal, so `release` must not read or + // write any signal value. + const owner = node.ownerRegistry + if (owner !== undefined) { + owner.release(node.ownerPath, node) + } + return + } + stopEffect(node as EffectNode) + }, +}) + +export class SignalNode implements ReactiveSignal, ReactiveNode { + currentValue: T + pendingValue: T + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined + flags: number = Mutable + /** Set when the registry owns this node; drives release-on-unwatch. */ + ownerRegistry: SignalOwner | undefined + ownerPath: string + + constructor(value: T, owner?: SignalOwner, path?: string) { + this.currentValue = value + this.pendingValue = value + this.ownerRegistry = owner + this.ownerPath = path ?? '' + } + + get(): T { + if (this.flags & Dirty) { + if (updateSignal(this)) { + const subs = this.subs + if (subs !== undefined) { + system.shallowPropagate(subs) + } + } + } + const sub = activeSub + if (sub !== undefined) { + system.link(this, sub, cycle) + } + return this.currentValue + } + + set(value: T): void { + if (this.pendingValue !== (this.pendingValue = value)) { + this.flags = Mutable | Dirty + const subs = this.subs + if (subs !== undefined) { + system.propagate(subs, !!runDepth) + if (!batchDepth) { + flush() + } + } + } + } +} + +export class ComputedNode implements ReactiveComputed, ReactiveNode { + value: T | undefined = undefined + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined + deps: Link | undefined = undefined + depsTail: Link | undefined = undefined + flags: number = 0 + getter: (previousValue?: T) => T + + constructor(getter: (previousValue?: T) => T) { + this.getter = getter + } + + get(): T { + const flags = this.flags + if ( + flags & Dirty || + (flags & Pending && + (system.checkDirty(this.deps!, this) || + ((this.flags = flags & ~Pending), false))) + ) { + if (updateComputed(this)) { + const subs = this.subs + if (subs !== undefined) { + system.shallowPropagate(subs) + } + } + } else if (!flags) { + this.flags = Mutable | RecursedCheck + const prevSub = setActiveSub(this) + try { + this.value = this.getter() + } finally { + activeSub = prevSub + this.flags &= ~RecursedCheck + } + } + const sub = activeSub + if (sub !== undefined) { + system.link(this, sub, cycle) + } + return this.value as T + } +} + +/** + * Covers both effects and effect scopes. A scope is an effect node with + * no `fn` and no `cleanup`: it exists only to own child effects through + * the dep links they create against it, so that stopping it cascades + * `unwatched` down to them. + * + * A scope is never queued by `notify`, which only runs for nodes + * carrying the `Watching` flag — scopes are created `Mutable`. + */ +interface EffectNode extends ReactiveNode { + fn?: () => void | (() => void) + cleanup?: (() => void) | undefined + deps: Link | undefined + depsTail: Link | undefined + subs: Link | undefined + subsTail: Link | undefined + flags: number +} + +function setActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined { + const prevSub = activeSub + activeSub = sub + return prevSub +} + +export function startBatch(): void { + ++batchDepth +} + +export function endBatch(): void { + if (!--batchDepth) { + flush() + } +} + +export function signal( + initialValue: T, + owner?: SignalOwner, + path?: string, +): SignalNode { + return new SignalNode(initialValue, owner, path) +} + +export function computed(getter: (previousValue?: T) => T): ComputedNode { + return new ComputedNode(getter) +} + +export function effect(fn: () => void | (() => void)): () => void { + const e: EffectNode = { + fn, + cleanup: undefined, + subs: undefined, + subsTail: undefined, + deps: undefined, + depsTail: undefined, + flags: Watching | RecursedCheck, + } + const prevSub = setActiveSub(e) + if (prevSub !== undefined) { + system.link(e, prevSub, 0) + prevSub.flags |= HasChildEffect + } + try { + ++runDepth + e.cleanup = e.fn!() ?? undefined + } finally { + --runDepth + activeSub = prevSub + e.flags &= ~RecursedCheck + } + return () => stopEffect(e) +} + +/** + * Run `fn` with a scope node installed as the active sub, so every + * effect created inside links itself to that scope. Stopping the scope + * unlinks those deps, which drops each child effect's last subscriber + * and cascades disposal through `unwatched`. + * + * @param fn - Runs once, immediately, with the scope active. + * @returns A dispose function that stops the scope and every effect + * created inside it. + */ +export function effectScope(fn: () => void): () => void { + const e: EffectNode = { + deps: undefined, + depsTail: undefined, + subs: undefined, + subsTail: undefined, + flags: Mutable, + } + const prevSub = setActiveSub(e) + if (prevSub !== undefined) { + system.link(e, prevSub, 0) + prevSub.flags |= HasChildEffect + } + try { + fn() + } finally { + activeSub = prevSub + } + return () => stopEffect(e) +} + +/** + * Unlink any child effects this sub created on its previous run. They + * are re-created by the run that follows. + * + * @param sub - The effect or scope whose child effects are dropped. + * @returns Nothing. + */ +function unlinkChildEffects(sub: ReactiveNode): void { + let current = sub.depsTail + while (current !== undefined) { + const prev = current.prevDep + const dep = current.dep + if (!isComputedNode(dep) && !isSignalNode(dep)) { + system.unlink(current, sub) + } + current = prev + } +} + +function updateComputed(c: ComputedNode): boolean { + if (c.flags & HasChildEffect) { + unlinkChildEffects(c) + } + c.depsTail = undefined + c.flags = Mutable | RecursedCheck + const prevSub = setActiveSub(c) + try { + ++cycle + const oldValue = c.value + return oldValue !== (c.value = c.getter(oldValue)) + } finally { + activeSub = prevSub + c.flags &= ~RecursedCheck + purgeDeps(c) + } +} + +function updateSignal(s: SignalNode): boolean { + s.flags = Mutable + return s.currentValue !== (s.currentValue = s.pendingValue) +} + +function run(e: EffectNode): void { + const flags = e.flags + if (flags & Dirty || (flags & Pending && system.checkDirty(e.deps!, e))) { + if (flags & HasChildEffect) { + unlinkChildEffects(e) + } + if (e.cleanup) { + runCleanup(e) + if (!e.flags) { + return + } + } + e.depsTail = undefined + e.flags = Watching | RecursedCheck + const prevSub = setActiveSub(e) + try { + ++cycle + ++runDepth + e.cleanup = e.fn!() ?? undefined + } finally { + --runDepth + activeSub = prevSub + e.flags &= ~RecursedCheck + purgeDeps(e) + } + } else if (e.deps !== undefined) { + e.flags = Watching | (flags & HasChildEffect) + } +} + +function flush(): void { + try { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]! + queued[notifyIndex++] = undefined + run(effect) + } + } finally { + while (notifyIndex < queuedLength) { + const effect = queued[notifyIndex]! + queued[notifyIndex++] = undefined + effect.flags |= Watching | Recursed + } + notifyIndex = 0 + queuedLength = 0 + } +} + +function runCleanup(e: EffectNode): void { + const cleanup = e.cleanup + e.cleanup = undefined + const prevSub = activeSub + activeSub = undefined + try { + cleanup!() + } finally { + activeSub = prevSub + } +} + +/** + * Dispose an effect or an effect scope: drop its flags, unlink every + * dep it holds (which is what cascades `unwatched` down through + * computeds to path signals), detach it from its parent, then run its + * cleanup if it has one. + * + * @param e - The effect or scope node to dispose. + * @returns Nothing. + */ +function stopEffect(e: EffectNode): void { + e.flags = 0 + disposeAllDepsInReverse(e) + const sub = e.subs + if (sub !== undefined) { + system.unlink(sub) + } + if (e.cleanup) { + runCleanup(e) + } +} + +function disposeAllDepsInReverse(sub: ReactiveNode): void { + let current = sub.depsTail + while (current !== undefined) { + const prev = current.prevDep + system.unlink(current, sub) + current = prev + } +} + +function purgeDeps(sub: ReactiveNode): void { + const depsTail = sub.depsTail + let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps + while (dep !== undefined) { + dep = system.unlink(dep, sub) + } +} diff --git a/src/signals/trackingProxy.ts b/src/signals/trackingProxy.ts new file mode 100644 index 000000000..78e389ee1 --- /dev/null +++ b/src/signals/trackingProxy.ts @@ -0,0 +1,691 @@ +import { + buildIdentityPath, + encodePathSegment, + findKeyField, + getKeyValue, + joinPath, + keysMetaPath, +} from './arrayKeys' +import { + isOverriddenArrayMethod, + createArrayMethodInterceptor, +} from './arrayMethodOverrides' +import { throwStateMutationError } from './mutationError' +import type { PathSignalRegistry } from './pathSignalRegistry' + +function isObjectOrArray(v: unknown): v is object { + return v !== null && typeof v === 'object' +} + +function isPlainObject(v: object): boolean { + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +// Fast check for array-index-like property names ('0', '42', ...). +// Canonical array index strings always start with a digit; named array +// props ('length', 'includes', ...) never do. Cheaper than Number()+isNaN +// on the hottest trap path (element scans). +function isIndexProp(prop: string): boolean { + const c = prop.charCodeAt(0) + return c >= 48 && c <= 57 +} + +// Dev-mode check: how many consecutive memoized re-reads of one primitive +// path within a single evaluation trigger the "hoist this read" warning. +const REPEATED_READ_WARN_THRESHOLD = 100 + +const repeatedReadWarnings = new WeakMap>() + +function warnRepeatedRead(registry: PathSignalRegistry, pathKey: string) { + let warned = repeatedReadWarnings.get(registry) + if (!warned) { + warned = new Set() + repeatedReadWarnings.set(registry, warned) + } + if (warned.has(pathKey)) return + warned.add(pathKey) + console.warn( + `[react-redux] useSignalSelector: a selector read '${pathKey}' ` + + `${REPEATED_READ_WARN_THRESHOLD}+ times in a single evaluation. ` + + 'Reads through the tracked state proxy cost more than plain property ' + + 'reads — hoist the value into a local before looping.', + ) +} + +/** + * Maps proxy objects to their path keys. + * Used by useSignalSelector to detect when a selector returns a proxy (object) + * and explicitly establish a dependency on that object's signal. + */ +const proxyPathMap = new WeakMap() + +/** + * Maps tracking proxies back to their raw target objects. + * Used to unwrap proxy arguments in array methods like includes/indexOf + * where identity comparison needs to work against raw array elements. + */ +const proxyTargetMap = new WeakMap() + +/** + * Maps scan-recorder proxies (used by array method callbacks) to their + * mutable holders. A recorder proxy is reused across all elements of a + * scan, reading from `holder.current`. unwrap() consults this map so + * identity comparisons inside callbacks can be unwrapped correctly. + */ +const recordingHolders = new WeakMap() + +/** + * Register a scan-recorder proxy so unwrap() can resolve it to the + * element currently being scanned. + * @param proxy - The recorder proxy passed to array method callbacks + * @param holder - Mutable holder whose `current` is the raw element + * @returns void + */ +export function registerRecordingHolder( + proxy: object, + holder: { current: unknown }, +): void { + recordingHolders.set(proxy, holder) +} + +/** + * Tracks which object-typed proxy accesses are "leaf" accesses — + * objects that were read by the selector but never had their properties + * accessed (i.e., used only for identity comparison like `===`). + * + * After the selector runs, leaf objects need their version signals + * explicitly read so that identity changes (ref swaps) are tracked. + */ +export interface LeafObjectTracker { + /** All paths where an object/array value was read, mapped to their raw values */ + accessedObjects: Map + /** Paths that had children accessed (i.e., were traversed, not leaves) */ + traversedPaths: Set + /** Plain objects whose keys were enumerated this evaluation, with the + * dependency reads deferred until `finalizeDependencies` decides + * whether they collapse onto the object's own version signal. */ + enumerated: Map +} + +/** + * Deferred reads for one enumerated object. A spread (`{...obj}`), + * `Object.keys(obj).map(k => obj[k])`, `Object.values`, `Object.entries`, + * `shallowEqual`, and `JSON.stringify` all call `ownKeys` and then read + * every own key. Reading every own value of an immutably updated object + * depends on exactly the same thing as the object's identity, so once the + * evaluation ends and the reads are known to be complete, one version + * signal on the object replaces a signal per key plus the keys-meta + * signal. An incomplete enumeration (`Object.keys(obj).length`, or + * enumerating and reading a subset) replays the deferred reads instead. + */ +export interface EnumerationRecord { + target: object + keysRead: Set + leaves: Array<[pathKey: string, value: unknown]> + objects: Array<[pathKey: string, value: object]> +} + +/** + * Create an empty tracker for one selector evaluation. + * @returns A fresh LeafObjectTracker + */ +export function createLeafTracker(): LeafObjectTracker { + return { + accessedObjects: new Map(), + traversedPaths: new Set(), + enumerated: new Map(), + } +} + +/** + * Register the dependencies an evaluation deferred: collapse or replay + * each enumerated object, then read version signals for leaf objects + * (read but never traversed — identity-only usage). Must run inside the + * same reactive computation that ran the selector, after it returned. + * @param tracker - The tracker installed for the evaluation + * @param registry - Signal registry for dependency tracking + * @returns void + */ +export function finalizeDependencies( + tracker: LeafObjectTracker, + registry: PathSignalRegistry, +): void { + // Pass 1: decide which enumerations are complete. A collapsed object's + // version signal fires on any change beneath it, so every deferred read + // under a collapsed ancestor is redundant and gets skipped in pass 2 + // (JSON.stringify collapses each nested object; only the outermost + // needs a signal). Records are checked against ancestors, not order, + // because a child can be enumerated before its parent. + const collapsed: string[] = [] + for (const [objPath, record] of tracker.enumerated) { + tracker.traversedPaths.add(objPath) + if (isCompleteEnumeration(record)) { + collapsed.push(objPath) + } + } + const underCollapsed = (path: string): boolean => { + for (let i = 0; i < collapsed.length; i++) { + const c = collapsed[i] + if ( + path.length > c.length && + path.startsWith(c) && + path[c.length] === '.' + ) { + return true + } + } + return false + } + + // Pass 2: read one version signal per collapsed object; replay the + // deferred reads for incomplete enumerations. + for (const [objPath, record] of tracker.enumerated) { + if (underCollapsed(objPath)) continue + if (isCompleteEnumeration(record)) { + registry.getOrCreate(objPath, record.target).get() + continue + } + registry + .getOrCreate(keysMetaPath(objPath), Object.keys(record.target)) + .get() + for (let i = 0; i < record.leaves.length; i++) { + const [pathKey, value] = record.leaves[i] + registry.getOrCreate(pathKey, value).get() + } + for (let i = 0; i < record.objects.length; i++) { + const [pathKey, value] = record.objects[i] + registry.ensurePrefix(pathKey) + tracker.accessedObjects.set(pathKey, value) + } + } + + // Skip the root: its version signal is never fired by diff. + for (const [objPath, rawValue] of tracker.accessedObjects) { + if ( + objPath !== '' && + !tracker.traversedPaths.has(objPath) && + !underCollapsed(objPath) + ) { + registry.getOrCreate(objPath, rawValue).get() + } + } +} + +function isCompleteEnumeration(record: EnumerationRecord): boolean { + const ownKeys = Object.keys(record.target) + for (let i = 0; i < ownKeys.length; i++) { + if (!record.keysRead.has(ownKeys[i])) return false + } + return true +} + +/** + * Type for the proxy cache. Exported so the registry can own one. + * Caches proxies by their target object identity. + * Since Immer uses structural sharing, unchanged subtrees keep the same + * object reference across state snapshots — so we can reuse their proxies. + * + * KNOWN TRADEOFF: an object aliased at two paths (e.g. + * `state.selected = state.items[0]`) reuses the proxy created for + * whichever path was read first, so child reads through the second path + * register signals under the first path. Per-path proxies would fix the + * attribution but break identity comparison (`state.selected === + * state.items[0]` must hold, and two proxies are never `===`). The alias + * path itself still gets a version-signal dependency via the leaf-object + * tracker, so replacing the aliased object re-runs the selector — the + * misattribution can cause excess re-runs, never missed ones. + */ +export type ProxyCache = WeakMap + +/** + * Get the path key associated with a tracking proxy, or undefined if not a proxy. + * @param value - The value to check + * @returns The path string, or undefined if not a tracking proxy + */ +export function getProxyPath(value: unknown): string | undefined { + if (value !== null && typeof value === 'object') { + return proxyPathMap.get(value) + } + return undefined +} + +/** + * Get the raw target of a tracking proxy, or undefined if the value is + * not a tracking proxy. Unlike unwrap(), this distinguishes "was a + * proxy" from "was already raw", which the result-untracking walk needs + * (proxies mark subtree boundaries: everything below a target is raw). + * @param value - The value to check + * @returns The raw target, or undefined if not a tracking proxy + */ +export function getProxyTarget(value: object): object | undefined { + return proxyTargetMap.get(value) +} + +/** + * Register a proxy→target mapping for a proxy created outside this + * module (the coarse-tier probe proxy). Makes `unwrap` and + * `untrackResult` treat it like any tracking proxy. + * @param proxy - The proxy object + * @param target - The raw object it wraps + * @returns void + */ +export function registerProxyTarget(proxy: object, target: object): void { + proxyTargetMap.set(proxy, target) +} + +/** + * Get the raw target object from a tracking proxy, or the value itself if not a proxy. + * + * Use this when you need identity comparison between values that may be + * tracking proxies. Since `proxy === rawObject` is always `false` in JS, + * unwrapping both sides allows correct identity checks: + * + * ```ts + * import { unwrap } from 'react-redux/signals' + * + * const selector = (state) => { + * const current = unwrap(state.current) + * return state.items.find(item => item === current) + * } + * ``` + * + * Safe to call on non-proxy values — returns them unchanged. + * + * @param value - The value to unwrap (proxy or raw) + * @returns The raw target object, or the original value if not a tracking proxy + */ +export function unwrap(value: T): T { + if (value !== null && typeof value === 'object') { + const target = proxyTargetMap.get(value as object) + if (target !== undefined) return target as T + const holder = recordingHolders.get(value as object) + if (holder !== undefined) return holder.current as T + } + return value +} + +/** + * Path key for an object element of a tracked array: the identity path + * (`items.{id:42}`) when the array has a detectable key field, otherwise + * the index path (`items.3`). Detects and records the array's key field + * on first use. + * @param registry - Signal registry holding per-array metadata + * @param arrayPath - Path of the containing array + * @param index - The element's index (number or canonical index string) + * @param element - The raw element object + * @returns The element's path key + */ +export function getElementPathKey( + registry: PathSignalRegistry, + arrayPath: string, + index: number | string, + element: object, +): string { + if (!Array.isArray(element)) { + let meta = registry.getArrayMeta(arrayPath) + if (!meta) { + const keyField = findKeyField(element) + if (keyField) { + meta = { keyField, entityMap: new Map() } + registry.setArrayMeta(arrayPath, meta) + } + } + if (meta) { + const kv = getKeyValue(element, meta.keyField) + if (kv !== undefined) { + return buildIdentityPath(arrayPath, meta.keyField, kv) + } + } + } + return joinPath( + arrayPath, + typeof index === 'number' ? index : encodePathSegment(index), + ) +} + +/** + * Creates a read-only tracking proxy that wraps frozen Redux state. + * + * On property access, the proxy: + * 1. Reads the corresponding path signal (establishing a reactive dependency) + * 2. Returns the actual frozen value (primitives) or a nested tracking proxy (objects/arrays) + * + * For frozen state compatibility: the proxy target is a thin unfrozen shell + * that mirrors the real state's structure. This avoids the ES Proxy invariant + * that forbids returning different values for non-configurable properties. + * The proxy reads actual values from the real frozen state object. + * + * Child proxies are cached in `registry.proxyCache`, keyed by target + * identity. The cache persists across evaluations and dispatches, so an + * unchanged subtree yields the same proxy object every time — selector + * results built from those proxies stay referentially equal. The root + * proxy is the exception: it is created fresh on every call so that + * caches keyed on the state argument cannot skip the selector body. + * @param target - The frozen state object to wrap + * @param parentPath - Dot-separated path to this object in the state tree + * @param registry - Signal registry for dependency tracking + * @param cache - Proxy cache for deduplication + * @param leafTracker - Optional tracker for detecting leaf object accesses. + * When provided, it becomes the ACTIVE tracker for the registry: all + * proxy traps (including those of previously cached proxies) record + * into it until another evaluation installs a different tracker. + * @returns A tracking proxy wrapping the target + */ +export function createTrackingProxy( + target: T, + parentPath: string, + registry: PathSignalRegistry, + cache: ProxyCache, + leafTracker?: LeafObjectTracker, +): T { + // Install the tracker for this evaluation. Traps read the holder at + // access time instead of closing over a tracker, so proxies cached by + // an earlier evaluation record into the current evaluation's tracker. + if (leafTracker !== undefined) { + registry.leafTrackerHolder.current = leafTracker + } + + // The root proxy is never cached: every evaluation gets a fresh one. + // Memoized selectors (Reselect's argsMemoize, WeakMap caches keyed on + // the state argument) short-circuit when handed an argument they have + // seen before, and a short-circuited call reads no state properties, so + // the evaluation would record no dependencies. A fresh root defeats + // that cache every time. Result functions stay memoized because their + // inputs are child proxies, which ARE cached by target identity. + const isRoot = parentPath === '' + if (isRoot) { + registry.evalEpoch++ + } else { + const cached = cache.get(target) + if (cached) return cached as T + } + + // Last-read memo for primitive leaves. Within one evaluation a repeated + // read of the same prop is idempotent: state is immutable so the value + // cannot change, the signal dependency is already linked, and the leaf + // tracker already marked this object as traversed. Selectors that loop + // over a collection while comparing against one proxied field + // (`items.filter(i => i.ownerId === user.id)`) hit this path once per + // element, so skipping the path-key/registry/signal lookups matters. + // The memo is scoped by registry.evalEpoch, not by tracker identity: + // useSignalSelector reuses one tracker across evaluations, and a stale + // hit on a later evaluation would silently drop the dependency. + let lastEpoch = -1 + let lastProp: string | undefined + let lastValue: unknown + let repeatCount = 0 + + // The enumeration record for this object is created by this proxy's own + // ownKeys trap, so the get trap can find it with an epoch compare instead + // of a Map lookup per read. Element scans over non-enumerated objects are + // the hot path and must not pay for the lookup. + let enumEpoch = -1 + let enumRecord: EnumerationRecord | undefined + const currentEnumeration = () => + enumEpoch === registry.evalEpoch ? enumRecord : undefined + + // Use an unfrozen shell as the proxy target to avoid ES Proxy invariant + // violations with frozen objects. The shell copies the target's prototype + // so that Array.isArray, instanceof, etc. work correctly. + const shell = Array.isArray(target) + ? [] + : Object.create(Object.getPrototypeOf(target)) + + // Per-proxy cache of prop -> full path string. parentPath is fixed for + // this proxy's lifetime, so path keys are stable. Reusing the same + // string instance avoids re-allocating on every read AND lets V8 cache + // the string hash, speeding up the registry Map lookups downstream. + // Property keys are encoded so reserved path characters in state keys + // (dots in RTKQ cache keys, literal '@@keys', etc.) can't collide with + // other paths or meta segments. The cache amortizes the encoding. + // Root proxies are not reused, so they share the registry's cache. + const pathKeyCache = isRoot + ? registry.rootPathKeyCache + : new Map() + function getPathKey(prop: string): string { + let key = pathKeyCache.get(prop) + if (key === undefined) { + key = joinPath(parentPath, encodePathSegment(prop)) + pathKeyCache.set(prop, key) + } + return key + } + + const proxy = new Proxy(shell, { + get(_obj, prop, _receiver) { + // Symbols: read from actual target (iterator protocol, toStringTag, etc.) + if (typeof prop === 'symbol') return Reflect.get(target, prop) + + if (prop === lastProp && registry.evalEpoch === lastEpoch) { + // A repeat read still counts toward a complete enumeration (a + // field read directly and then again via a spread). The first + // read already registered whatever dependency it needed. + currentEnumeration()?.keysRead.add(prop) + if (process.env.NODE_ENV !== 'production') { + if (++repeatCount === REPEATED_READ_WARN_THRESHOLD) { + warnRepeatedRead(registry, getPathKey(prop)) + } + } + return lastValue + } + + const value = (target as Record)[prop] + const leafTracker = registry.leafTrackerHolder.current + + // Functions: intercept array methods to avoid per-element proxy creation + if (typeof value === 'function') { + if (Array.isArray(target) && isOverriddenArrayMethod(prop as string)) { + // Mark the array as traversed: the interceptor registers its own + // precise dependencies (column/structural signals or coarse + // fallback), so the leaf tracker must NOT also subscribe to the + // array's version signal (which fires on every array change). + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + return createArrayMethodInterceptor( + target, + prop as string, + registry, + parentPath, + ) + } + return value + } + + // Primitive array elements: subscribe to the array's version signal + // instead of creating one signal per index. Primitive arrays (number + // history buffers, id lists, etc.) can be huge — per-index signals + // cost O(N) creation at mount for little precision benefit. The + // array version fires on any array change (diffArray always bumps + // it), so this over-fires only when a selector reads a subset of a + // primitive array — consistent with erring toward firing. + // Checked BEFORE building pathKey: element scans are the hottest + // path through this trap, and the coarse signal only needs the + // (stable, hash-memoized) parentPath string — no per-read allocs. + // Root arrays (parentPath === '') keep per-index signals since the + // root version signal is never fired by diff. + if ( + Array.isArray(target) && + parentPath !== '' && + isIndexProp(prop as string) && + !isObjectOrArray(value) + ) { + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + } + registry.getOrCreate(parentPath, target).get() + return value + } + + let pathKey = getPathKey(prop as string) + + if (isObjectOrArray(value)) { + // Array elements use the identity path (items.{id:42}) when the + // array has a key field, so signals survive reorders. + if (Array.isArray(target) && isIndexProp(prop as string)) { + pathKey = getElementPathKey(registry, parentPath, prop, value) + } + + // Non-plain objects (Date, Map, Set, class instances): return the + // raw object, tracked by reference only. Wrapping them in a proxy + // shell breaks prototype methods that need internal slots + // ("this is not a Date object"), and the diff can't recurse into + // them anyway. Reading the version signal here means a ref swap + // at this path re-runs the selector. + if (!Array.isArray(value) && !isPlainObject(value as object)) { + if (leafTracker) { + leafTracker.traversedPaths.add(parentPath) + // Counts toward a complete enumeration; the reference read + // stays immediate and is simply redundant if it collapses. + currentEnumeration()?.keysRead.add(prop as string) + } + registry.getOrCreate(pathKey, value).get() + return value + } + + // Return cached child proxy (createTrackingProxy checks cache internally). + // No tracker argument: the trap already reads the registry's + // active-tracker holder, which this evaluation installed. + const childProxy = createTrackingProxy( + value as object, + pathKey, + registry, + cache, + ) + + // Enumeration in progress on this object: defer the prefix and + // leaf-object bookkeeping to finalizeDependencies. + const enumeration = currentEnumeration() + if (enumeration !== undefined) { + if (!enumeration.keysRead.has(prop as string)) { + enumeration.keysRead.add(prop as string) + enumeration.objects.push([pathKey, value as object]) + } + return childProxy + } + + // Register in prefix index (for hasPrefix/diff tracking) but DON'T + // create a signal. This avoids allocating signals for intermediate + // objects that are only traversed, not read as terminal values. + registry.ensurePrefix(pathKey) + + if (leafTracker) { + // Mark parent as traversed (it has children being accessed) + leafTracker.traversedPaths.add(parentPath) + // Track this object access — may be a leaf (identity-only usage) + leafTracker.accessedObjects.set(pathKey, value as object) + } + + return childProxy + } + + if (leafTracker) { + // Enumeration in progress on this object: defer the leaf read to + // finalizeDependencies. + const enumeration = currentEnumeration() + if (enumeration !== undefined) { + if (!enumeration.keysRead.has(prop as string)) { + enumeration.keysRead.add(prop as string) + enumeration.leaves.push([pathKey, value]) + } + lastEpoch = registry.evalEpoch + lastProp = prop as string + lastValue = value + repeatCount = 0 + return value + } + // Mark parent as traversed (it has children being accessed) + leafTracker.traversedPaths.add(parentPath) + } + + // Primitive leaf: read signal to establish dependency. + // Note: we intentionally do NOT subscribe to the parent object's + // version signal here. Doing so causes sibling fan-out (any sibling + // change fires the parent version under Immer structural sharing) + // and zombie-subscription cascades after prune. Parent deletion is + // covered by prune firing this leaf signal, and key reappearance is + // covered by diff's added-key handling firing the leaf path. + registry.getOrCreate(pathKey, value).get() + + lastEpoch = registry.evalEpoch + lastProp = prop as string + lastValue = value + repeatCount = 0 + + return value + }, + + // Track when selectors iterate keys (Object.keys, for...in, spread, etc.) + ownKeys(_obj) { + const leafTracker = registry.leafTrackerHolder.current + // Plain non-root objects defer: if every own key gets read before + // the evaluation ends, one version signal on the object covers the + // keys and all the values. Arrays already collapse primitive element + // reads onto the array's version signal, and the root's version + // signal is never fired by diff, so both keep the immediate read. + if (leafTracker && !isRoot && !Array.isArray(target)) { + let record = leafTracker.enumerated.get(parentPath) + if (record === undefined) { + record = { target, keysRead: new Set(), leaves: [], objects: [] } + leafTracker.enumerated.set(parentPath, record) + } + enumRecord = record + enumEpoch = registry.evalEpoch + } else { + registry + .getOrCreate(keysMetaPath(parentPath), Reflect.ownKeys(target)) + .get() + } + return Reflect.ownKeys(target) + }, + + // Track has() checks for conditional property access (e.g., 'key' in obj) + has(_obj, prop) { + if (typeof prop === 'symbol') return Reflect.has(target, prop) + const pathKey = getPathKey(prop as string) + registry + .getOrCreate(pathKey, (target as Record)[prop]) + .get() + return Reflect.has(target, prop) + }, + + // Delegate to real target for property descriptors + getOwnPropertyDescriptor(_obj, prop) { + const desc = Object.getOwnPropertyDescriptor(target, prop) + if (desc) { + // Mark as configurable so the proxy can return different values + // (our proxy may return child proxies for object properties) + return { ...desc, configurable: true } + } + return desc + }, + + // Report the real target's prototype + getPrototypeOf(_obj) { + return Object.getPrototypeOf(target) + }, + + // Report the real target's extensibility + isExtensible(_obj) { + return Object.isExtensible(target) + }, + + // Prevent mutation + set(_obj, prop) { + throwStateMutationError('set', String(prop)) + }, + deleteProperty(_obj, prop) { + throwStateMutationError('delete', String(prop)) + }, + }) as T + + // Cache by target identity — unchanged Immer subtrees reuse same proxy + if (!isRoot) { + cache.set(target, proxy) + } + proxyPathMap.set(proxy as object, parentPath) + proxyTargetMap.set(proxy as object, target) + + return proxy +} diff --git a/src/signals/types.ts b/src/signals/types.ts new file mode 100644 index 000000000..0750abb92 --- /dev/null +++ b/src/signals/types.ts @@ -0,0 +1,32 @@ +import type { SignalOwner } from './reactiveSystem' + +export type PathKey = string // dot-joined: "todos.0.text", "counters.counter1.value" + +export interface ReactiveSignal { + get(): T + set(value: T): void +} + +export interface ReactiveComputed { + get(): T +} + +export interface SignalEngine { + /** + * Create a signal. When `owner` and `path` are supplied, the signal + * calls `owner.release(path, node)` as soon as it loses its last + * subscriber, which is how `pathSignalRegistry` drops a path once no + * mounted component watches it. + */ + signal(value: T, owner?: SignalOwner, path?: string): ReactiveSignal + computed(fn: () => T): ReactiveComputed + /** Create an effect. Returns a dispose function that stops it. */ + effect(fn: () => void): () => void + batch(fn: () => void): void + createScope(): SignalScope +} + +export interface SignalScope { + run(fn: () => T): T + stop(): void +} diff --git a/src/signals/untrack.ts b/src/signals/untrack.ts new file mode 100644 index 000000000..42c457f0f --- /dev/null +++ b/src/signals/untrack.ts @@ -0,0 +1,81 @@ +import { getProxyTarget } from './trackingProxy' + +/** + * Strip tracking proxies from a selector result so components, effects, + * DevTools, and dispatch payloads only ever see raw (frozen) state. + * + * proxy-memoize style: walk the result, swap every tracking proxy for its + * raw target in place, stop descending at each proxy (everything below a + * raw target is raw). + * + * Derived containers built by the selector are mutated IN PLACE (proxy + * values swapped for raw targets), preserving container identity across + * evaluations — a memoized (reselect-cached) array keeps its identity, + * and re-untracking it is an idempotent no-op. + * @param result - The selector's return value + * @returns The result with all tracking proxies replaced by raw state + */ +export function untrackResult(result: R): R { + return untrackRecursive(result, null) +} + +function untrackRecursive(value: R, seen: WeakSet | null): R { + if (value === null || typeof value !== 'object') return value + + // Tracking proxy: swap for the raw target. No descent — everything + // reachable from a raw state object is raw. + const target = getProxyTarget(value) + if (target !== undefined) return target as R + + // Frozen non-proxy objects can't be repaired in place. Raw state + // subtrees (already frozen, can't contain proxies) are the common + // case — skipping them also avoids walking large unwrapped subtrees. + // A user-frozen derived container holding proxies is unsupported. + if (Object.isFrozen(value)) return value + + if (seen === null) seen = new WeakSet() + else if (seen.has(value)) return value + seen.add(value) + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const v: unknown = value[i] + const u = untrackRecursive(v, seen) + if (u !== v) value[i] = u + } + return value + } + + if (value instanceof Map) { + for (const [k, v] of value) { + const u = untrackRecursive(v, seen) + if (u !== v) value.set(k, u) + } + return value + } + + if (value instanceof Set) { + let replacements: Array<[unknown, unknown]> | null = null + for (const v of value) { + const u = untrackRecursive(v, seen) + if (u !== v) (replacements ??= []).push([v, u]) + } + if (replacements) { + // Rebuild preserving insertion order is not possible with targeted + // deletes; proxies in Sets are rare enough that delete+add is fine. + for (const [v, u] of replacements) { + value.delete(v) + value.add(u) + } + } + return value + } + + const record = value as Record + for (const k of Object.keys(record)) { + const v = record[k] + const u = untrackRecursive(v, seen) + if (u !== v) record[k] = u + } + return value +} diff --git a/src/signals/useSignalSelector.ts b/src/signals/useSignalSelector.ts new file mode 100644 index 000000000..b81564d5b --- /dev/null +++ b/src/signals/useSignalSelector.ts @@ -0,0 +1,731 @@ +import type { Context } from 'react' +import { React } from '../utils/react' +import type { DevModeChecks, UseSelectorOptions } from '../hooks/useSelector' +import type { ReactReduxContextValue } from '../components/Context' +import type { EqualityFn } from '../types' +import { createProbeProxy } from './coarseSegments' +import type { CoarseSub } from './coarseSegments' +import type { SignalContextValue } from './context' +import { + createSignalContextHook, + ReactReduxContext, + useSignalContext, +} from './context' +import { + createLeafTracker, + createTrackingProxy, + finalizeDependencies, + getProxyPath, +} from './trackingProxy' +import { computed, effect, signal } from './reactiveSystem' +import { untrackResult } from './untrack' + +const { useRef, useMemo, useSyncExternalStore, useDebugValue } = React + +const refEquality: EqualityFn = Object.is + +// Returned by the computed when the selector threw. A unique sentinel +// (never a real result) so alien-signals sees the value as changed and +// keeps propagating to the effect — returning the previous result would +// cut off propagation and React would never be notified of the error. +const SELECTOR_THREW = Symbol('selector threw') as unknown + +// Only plain-object roots can be probed by the coarse tier: method calls +// through a proxy over a Map/Set/class-instance root would run with the +// proxy as `this` and break internal-slot access. +function isPlainObjectState(v: unknown): v is object { + if (v === null || typeof v !== 'object') return false + const proto = Object.getPrototypeOf(v) + return proto === Object.prototype || proto === null +} + +/** + * A React hook that selects state from a Redux store using signal-based + * dependency tracking. Only re-renders when the selected value actually + * changes, with O(k) selectivity where k = number of affected selectors. + * + * Must be used within a . + * @param selector - Function that extracts a value from the store state + * @param equalityFnOrOptions - Equality function, or an options object + * with `equalityFn` and `devModeChecks` (same shape as stock + * `useSelector`) + * @param useBoundSignalContext - Internal: the context hook to read the + * signal context from. Bound by `createSignalSelectorHook`. + * @returns The selected value + */ +const useSignalSelectorImpl = ( + selector: (state: S) => R, + equalityFnOrOptions: EqualityFn | UseSelectorOptions = {}, + useBoundSignalContext: () => SignalContextValue = useSignalContext, +): R => { + const { equalityFn = refEquality as EqualityFn, devModeChecks = {} } = + typeof equalityFnOrOptions === 'function' + ? { equalityFn: equalityFnOrOptions, devModeChecks: {} } + : equalityFnOrOptions + + if (process.env.NODE_ENV !== 'production') { + if (!selector) { + throw new Error(`You must pass a selector to useSignalSelector`) + } + if (typeof selector !== 'function') { + throw new Error( + `You must pass a function as a selector to useSignalSelector`, + ) + } + if (typeof equalityFn !== 'function') { + throw new Error( + `You must pass a function as an equality function to useSignalSelector`, + ) + } + } + + const reduxContext = useBoundSignalContext() + const { store, registry } = reduxContext + + // Track latest selector/equalityFn via refs. Updated during render + // (idempotent ref writes, same approach as React's + // useSyncExternalStoreWithSelector) so a selector closing over changed + // props is applied in THIS render, not a layout effect later. + const selectorRef = useRef(selector) + const equalityFnRef = useRef(equalityFn) + equalityFnRef.current = equalityFn + + // Dev-mode check settings. Per-hook `devModeChecks` overrides merge over + // the Provider-level defaults, matching stock useSelector. Kept in refs + // (idempotent render-phase writes) so the bridge closure always reads + // the latest values. + const devModeChecksRef = useRef>(devModeChecks) + devModeChecksRef.current = devModeChecks + const contextRef = useRef(reduxContext) + contextRef.current = reduxContext + + // Create the signal bridge once (stable across renders) + const bridge = useMemo(() => { + let currentResult: R + // Whether currentResult has ever been seeded. User equality + // functions must never be called with the unseeded value — stock + // useSelector never compares against a nonexistent previous result. + let hasResult = false + let notifyReact: (() => void) | null = null + let suppressNotify = false + // Set when the selector threw during an effect re-evaluation (classic + // zombie child: an entity was removed while a component selecting it + // is still mounted). getSnapshot retries and rethrows if it persists. + let pendingError: unknown = null + + // --- Coarse tier state --- + // The hook starts "unbuilt": no tracking proxies, no path signals, + // no alien-signals effect. At mount, one shallow probe records which + // top-level state keys the selector reads; the hook then waits in + // the segment index and is promoted to the deep tier on the first + // dispatch that changes one of those keys. + let built = false + let disposeEffect: (() => void) | null = null + let coarseSub: CoarseSub | null = null + let probeSegments = new Set() + // Footprint can't be gated: the selector read no top-level keys, + // returned the root, or enumerated root keys — or the root isn't a + // plain object. Such hooks build the deep tier eagerly. + let ungateable = false + // State ref that probeSegments/currentResult were probed against. + let probedState: unknown = null + // State ref that currentResult was last computed against. getSnapshot + // compares it to store.getState() to catch dispatches whose listener + // notification hasn't arrived yet. + let lastSnapshotState: unknown = null + // Freshness cache for getServerSnapshot, keyed on the serverState ref. + let lastServerState: unknown = null + let serverResult: R + + // Track leaf object accesses for identity comparison support. + // Objects read by the selector but never traversed deeper are + // "leaf objects" — their identity matters (e.g., `a === b`). + // The holder object is stable; its containers are swapped for fresh + // ones at the start of each evaluation (a computed never re-enters + // its own evaluation, so this is safe). + const leafTracker = createLeafTracker() + + // Bumped when the component re-renders with a different selector + // function (e.g., an inline selector closing over changed props). + // The computed reads it, so bumping forces a re-evaluation with the + // new closure — otherwise the selector would keep returning the old + // closure's result until an unrelated store change fired a signal. + const selectorVersionSignal = signal(0) + + // Force a fresh evaluation of the computed WITHOUT notifying React. + // Bumping the version signal dirties the computed; a plain .get() + // alone would return the cached value. On a selector error the + // computed records pendingError and yields the SELECTOR_THREW + // sentinel, which must never replace the last good result. + const recomputeInPlace = (): void => { + suppressNotify = true + try { + selectorVersionSignal.set(selectorVersionSignal.get() + 1) + const value = selectorComputed.get() + if (pendingError === null) { + // Apply the equality function before adopting. A selector that + // returns a new reference every call (`() => [1, 2, 3]`) paired + // with a custom equalityFn must keep the PREVIOUS reference, or + // every render hands React a new value and defeats the check. + if (!equalityFnRef.current(currentResult, value)) { + currentResult = value + } + } + } finally { + suppressNotify = false + } + } + + const setSelector = (nextSelector: (state: S) => R): void => { + selectorRef.current = nextSelector + + if (built) { + // Recompute in place: this runs during render, and getSnapshot + // picks up the fresh value in the same pass. Scheduling a + // re-render here would loop forever for selectors that return a + // new reference on every run. + recomputeInPlace() + if (pendingError !== null) { + // The new closure threw — surface it in this render. + throw pendingError + } + return + } + + // Still in the coarse tier: the registered footprint reflects the + // OLD closure. Re-probe with the new one and swap the registration + // — `s => flag ? s.a : s.b` left registered under {a} alone would + // silently miss every change to b. + const state = store.getState() + if (isPlainObjectState(state)) { + // A probe throw propagates into this render (stock parity for + // selector swaps); the old registration stays until a working + // closure lands. + probe(state as S & object) + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = { + // Ungateable after the swap (returns root / enumerates keys): + // the deep effect can't be attached during render, so + // register a wildcard — the next root change promotes it. + segments: ungateable ? null : probeSegments, + onCoarseHit, + } + registry.segmentIndex.register(coarseSub) + } + // Not subscribed yet: subscribe() handles registration (and the + // eager build for ungateable footprints). + } else { + // Root became non-plain while never subscribed (a dispatch in + // the render→subscribe gap) — can't probe it. Don't build during + // render either (an uncommitted component would orphan the + // build). Invalidate the snapshot cache so getSnapshot re-runs + // the NEW closure in this render; subscribe() builds the deep + // tier on commit. + ungateable = true + lastSnapshotState = null + } + } + + // Dev-mode selector checks (stability / identity function), matching + // stock useSelector. They run inside the computed because that is the + // only place the selector executes. `firstRun` is per hook instance: + // 'once' means literally once, on the first evaluation — a later + // render-phase selector swap does NOT re-arm it. + let firstRun = true + const runDevModeChecks = (selected: R, proxy: S, rawState: S): void => { + const { stabilityCheck = 'once', identityFunctionCheck = 'once' } = + contextRef.current + const finalChecks: DevModeChecks = { + stabilityCheck, + identityFunctionCheck, + ...devModeChecksRef.current, + } + + if ( + finalChecks.stabilityCheck === 'always' || + (finalChecks.stabilityCheck === 'once' && firstRun) + ) { + // Re-running the selector against the same proxy registers the + // same dependencies (idempotent) and returns proxy-consistent + // values, so === / shallowEqual comparisons behave correctly. + const toCompare = selectorRef.current(proxy) + if (!equalityFnRef.current(selected, toCompare)) { + let stack: string | undefined = undefined + try { + throw new Error() + } catch (e) { + stack = (e as Error).stack + } + console.warn( + 'Selector ' + + (selectorRef.current.name || 'unknown') + + ' returned a different result when called with the same parameters. This can lead to unnecessary rerenders.' + + '\nSelectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization', + // `state` is the RAW store state, not the proxy the selector + // ran against — the warning is user-facing and a tracking + // proxy in the console would register phantom dependencies + // for anything the user expanded in devtools. + { state: rawState, selected, selected2: toCompare, stack }, + ) + } + } + + if ( + finalChecks.identityFunctionCheck === 'always' || + (finalChecks.identityFunctionCheck === 'once' && firstRun) + ) { + if ((selected as unknown) === proxy) { + let stack: string | undefined = undefined + try { + throw new Error() + } catch (e) { + stack = (e as Error).stack + } + console.warn( + 'Selector ' + + (selectorRef.current.name || 'unknown') + + ' returned the root state when called. With useSignalSelector this component will NEVER re-render, because signal dependencies are only created for the specific paths a selector reads — and this selector read none.' + + '\nSelect the smallest values your component needs instead of the entire state object.', + { stack }, + ) + } + } + + firstRun = false + } + + // The computed runs the selector through a tracking proxy, which + // establishes signal dependencies on the paths the selector reads. + // Intermediate object traversals don't create signal dependencies + // (avoiding "false sharing" where sibling changes cause re-runs); + // only leaf primitive reads create deps automatically. If the + // selector returns a proxy (object result), the terminal dependency + // is established by explicitly reading that object's signal below. + const selectorComputed = computed(() => { + selectorVersionSignal.get() + // pendingError reflects the LAST evaluation: a successful re-run + // clears an earlier error. + pendingError = null + try { + const state = store.getState() as S & object + lastSnapshotState = state + + leafTracker.accessedObjects = new Map() + leafTracker.traversedPaths = new Set() + leafTracker.enumerated = new Map() + + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selectorRef.current(proxy as S) + + if (process.env.NODE_ENV !== 'production') { + runDevModeChecks(result, proxy as S, state as S) + } + + // If the selector returned a tracking proxy (object), explicitly + // read its signal to establish a reactive dependency on that path. + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + + // Deferred reads: collapse complete enumerations onto object + // version signals, then track identity for leaf objects (read + // but never traversed, e.g. used only for `===`). + finalizeDependencies(leafTracker, registry) + + // Strip tracking proxies before the result crosses into React. + // Must run AFTER the dependency reads above (they need the proxies + // in hand). Everything downstream — equalityFn, getSnapshot, + // components, dispatch payloads — sees only raw state. + return untrackResult(result) + } catch (e) { + // The selector threw — typically a zombie child whose entity was + // removed in the same dispatch that will unmount it. The error + // CANNOT propagate from here: alien-signals evaluates dirty + // computeds during its flush (checkDirty), so an uncaught throw + // escapes store.dispatch and skips every remaining component's + // effect. Record it and yield the sentinel; the effect notifies + // React and getSnapshot re-evaluates — if a parent unmounts this + // component first the error dissolves, otherwise it rethrows + // into the render where an error boundary owns it (matching + // stock useSelector under React's useSyncExternalStore). + pendingError = e + return SELECTOR_THREW as R + } + }) + + // Create the alien-signals effect that drives the deep tier: fires + // when the computed value changes, applies the user's equalityFn, + // and notifies React. Extracted from subscribe() because promotion + // from the coarse tier (first coarse hit) also attaches it. + const attachEffect = (): void => { + let isFirst = true + disposeEffect = effect(() => { + const newValue = selectorComputed.get() + + if (pendingError !== null) { + // The evaluation threw (newValue is the SELECTOR_THREW + // sentinel — never adopt it). Notify React so getSnapshot + // can retry and surface or dissolve the error. + isFirst = false + if (!suppressNotify) { + notifyReact?.() + } + return + } + + if (isFirst) { + // First effect run — just establish tracking, don't notify + isFirst = false + return + } + + if (suppressNotify) { + // Render-phase selector swap (setSelector): adopt the value + // so the equality baseline is current, but let the ongoing + // render pick it up via getSnapshot instead of scheduling + // another render. Equality still applies: this effect runs + // synchronously inside recomputeInPlace, so skipping the check + // here would adopt the new reference before recomputeInPlace + // ever gets to reject it. + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + } + return + } + + // Apply user's equality function + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + notifyReact?.() + } + }) + } + + // Run the selector once through a one-level shallow probe proxy: + // seeds currentResult and records the coarse footprint (which + // top-level keys it read) without building tracking proxies or path + // signals. Throws propagate to the caller — mount render / selector + // swap, stock parity. + const probe = (state: S & object): void => { + const { proxy, record } = createProbeProxy(state) + const value = selectorRef.current(proxy as S) + if (process.env.NODE_ENV !== 'production') { + runDevModeChecks(value, proxy as S, state as S) + } + // Apply the equality function before adopting (except on the very + // first probe, when there is no previous result). A re-probe after + // a render-phase selector swap must keep the PREVIOUS reference + // for equal-but-new results, or the identity handed to React + // changes on every swap and defeats the equality check. + const fresh = untrackResult(value) + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + hasResult = true + } + probeSegments = record.segments + ungateable = record.segments.size === 0 || record.enumerated + probedState = state + lastSnapshotState = state + } + + // Promotion: a dispatch changed one of this hook's root segments. + // Build the deep tier — evaluate the computed (creating tracking + // proxies and path signals), attach the driving effect, and notify + // React if the value actually changed. Runs synchronously inside + // the Provider's onStateChange, before notifyNestedSubs. + const onCoarseHit = (): void => { + if (built || notifyReact === null) return + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = null + } + built = true + attachEffect() + // The effect's first run evaluated the computed (isFirst skip); + // this read returns the cached value. + const newValue = selectorComputed.get() + if (pendingError !== null) { + // Zombie-child window: the dispatch that woke us also removed + // the entity. getSnapshot retries and surfaces or dissolves it. + notifyReact?.() + return + } + if (!equalityFnRef.current(currentResult, newValue)) { + currentResult = newValue + notifyReact?.() + } + } + + // --- Mount seeding --- + // Probe the selector's coarse footprint and seed the initial value. + // A selector that throws on mount surfaces here, during render — + // same as stock useSelector. + // + // The deep tier is NEVER built during render, even for ungateable + // footprints. React only calls subscribe for components that commit, + // so a render-phase build has no owner until then: a component that + // suspends, a transition render that gets abandoned, or the extra + // bridge from StrictMode's useMemo double-invoke would orphan the + // computed and permanently anchor path signals in the registry. + // Until subscribe builds the deep tier, getSnapshot serves values by + // running the raw selector (see the unbuilt branch below). + const initialState = store.getState() + if (isPlainObjectState(initialState)) { + probe(initialState as S & object) + } else { + // Non-plain root: can't probe it. getSnapshot's unbuilt path runs + // the raw selector during render (throw-on-mount parity); + // subscribe() builds the deep tier on commit. + ungateable = true + } + + const getSnapshot = (): R => { + if (!built) { + // Deferred: no effect is watching yet. Serve fresh values for + // dispatches in the render→subscribe gap (and wildcard waits) + // by recomputing against RAW state, with no signals. Cached per + // state ref; equalityFn preserves the previous result's identity + // for equal-but-new references. A throw propagates into render — + // stock parity. + const state = store.getState() + if (state !== lastSnapshotState) { + lastSnapshotState = state + const fresh = untrackResult(selectorRef.current(state as S)) + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + hasResult = true + } + } + return currentResult + } + + if (pendingError !== null) { + // A previous evaluation threw. Retry against current state: + // the situation may have resolved (entity restored, or this is + // a fresh render pass after the parent re-rendered). Clear + // first — the effect's catch re-sets it if the selector still + // throws. On rethrow, LEAVE pendingError set so the next + // getSnapshot (React retries during render after a + // subscription-phase throw) re-evaluates instead of returning + // a stale value. + pendingError = null + recomputeInPlace() + if (pendingError !== null) { + throw pendingError + } + } + + // The store moved but its listeners haven't been notified yet + // (deferred notification, e.g. RTK's autoBatchEnhancer), so the + // signal effect hasn't refreshed currentResult. A render landing + // in that gap must still see current state, the same way stock + // useSelector reads store.getState() directly. Recompute against + // RAW state with no dependency changes. When the notification + // arrives the effect re-evaluates, finds the value equal, and + // stays quiet. + // + // Raw state does not guarantee a proxy-free result: a selector can + // close over values captured during an earlier tracked evaluation + // (RTK Query's `lastValue` ref holds the previous default result, + // whose `data` is a tracking proxy when `selectFromResult` is + // used) and return them. Untrack here as on the tracked path. + const state = store.getState() + if (state !== lastSnapshotState) { + lastSnapshotState = state + const fresh = untrackResult(selectorRef.current(state as S)) + if (!hasResult || !equalityFnRef.current(currentResult, fresh)) { + currentResult = fresh + hasResult = true + } + } + return currentResult + } + + return { + subscribe(onStoreChange: () => void): () => void { + notifyReact = onStoreChange + + if (!built) { + // A dispatch may have landed between render (probe) and + // subscribe. Re-probe so the registered footprint and seeded + // value reflect the state we're subscribing against. + const state = store.getState() + if (state !== probedState) { + if (isPlainObjectState(state)) { + try { + probe(state as S & object) + } catch { + // The selector threw against the newer state (zombie + // window). Fall back to the deep tier — its pendingError + // machinery owns surfacing (effect notify → getSnapshot + // rethrow into render). + built = true + recomputeInPlace() + } + } else { + built = true + recomputeInPlace() + } + } + + if (!built) { + if (ungateable) { + // Can't gate this footprint — build eagerly. On error the + // computed set pendingError; the effect's first run + // notifies and getSnapshot rethrows into render. + // Equality applies before adopting: the probe already + // seeded currentResult, and a selector that returns a + // new-but-equal reference must not change the identity + // React saw during render (that would force an immediate + // extra re-render at subscribe time). + built = true + const value = selectorComputed.get() + if ( + pendingError === null && + (!hasResult || !equalityFnRef.current(currentResult, value)) + ) { + currentResult = value + hasResult = true + } + } else { + coarseSub = { segments: probeSegments, onCoarseHit } + registry.segmentIndex.register(coarseSub) + } + } + } + + if (built && disposeEffect === null) { + attachEffect() + } + + return () => { + notifyReact = null + if (coarseSub !== null) { + registry.segmentIndex.unregister(coarseSub) + coarseSub = null + } + if (disposeEffect !== null) { + disposeEffect() + disposeEffect = null + } + } + }, + + getSnapshot, + + // Hydration snapshot. `` records the + // state the server rendered against; the client store may already + // have moved on, and returning its current value would produce a + // hydration mismatch. Runs the raw selector against the server + // state — no proxies, no signals, nothing to register — and + // caches per server-state reference so repeated calls in one + // hydration pass return an identical result. + getServerSnapshot(): R { + const { getServerState } = contextRef.current + if (getServerState === undefined) { + return getSnapshot() + } + const state = getServerState() + if (state !== lastServerState) { + lastServerState = state + serverResult = selectorRef.current(state as S) + } + return serverResult as R + }, + + setSelector, + } + }, [store, registry]) + + // Render-phase selector swap: if this render brought a different + // selector function (inline selector closing over changed props), + // re-evaluate now so getSnapshot returns the new closure's value in + // this same render. + if (selectorRef.current !== selector) { + bridge.setSelector(selector) + } + + // React only re-checks the snapshot after commit when getSnapshot's + // identity changed. Stock useSelector gets that for free from + // useSyncExternalStoreWithSelector, which derives a new getSnapshot per + // selector identity; a store change made in a layout effect is then + // rendered synchronously instead of waiting for the store listener. + // Hand React a wrapper keyed on the selector so the two match. + const getSnapshot = useMemo( + () => () => bridge.getSnapshot(), + [bridge, selector], + ) + + const selectedState = useSyncExternalStore( + bridge.subscribe, + getSnapshot, + bridge.getServerSnapshot, + ) + + useDebugValue(selectedState) + + return selectedState +} + +/** + * The signal-based `useSignalSelector` hook, including the `withTypes` + * helper for creating a pre-typed version: + * + * ```ts + * export const useAppSignalSelector = useSignalSelector.withTypes() + * ``` + */ +export interface UseSignalSelector { + ( + selector: (state: TState) => Selected, + equalityFnOrOptions?: + | EqualityFn> + | UseSelectorOptions>, + ): Selected + + withTypes: < + OverrideStateType extends StateType, + >() => UseSignalSelector +} + +/** + * Hook factory, which creates a `useSignalSelector` hook bound to a given + * context. Mirrors stock `createSelectorHook`. + * + * The context must be provided by a `` — + * the returned hook validates that the context value carries the signal + * registry and engine. + * + * @param context - Context passed to your ``. + * @returns A `useSignalSelector` hook bound to the given context. + */ +export function createSignalSelectorHook( + context: Context | null> = ReactReduxContext, +): UseSignalSelector { + const useBoundSignalContext = + context === ReactReduxContext + ? useSignalContext + : createSignalContextHook(context) + + const useSignalSelectorBound = ( + selector: (state: S) => R, + equalityFnOrOptions: EqualityFn | UseSelectorOptions = {}, + ): R => + useSignalSelectorImpl(selector, equalityFnOrOptions, useBoundSignalContext) + + const boundHook = Object.assign(useSignalSelectorBound, { + withTypes: () => boundHook, + }) as UseSignalSelector + + return boundHook +} + +export const useSignalSelector = /* @__PURE__ */ createSignalSelectorHook() diff --git a/test/benchmarks/reconcile/benchEngine.ts b/test/benchmarks/reconcile/benchEngine.ts new file mode 100644 index 000000000..486359ead --- /dev/null +++ b/test/benchmarks/reconcile/benchEngine.ts @@ -0,0 +1,56 @@ +/** + * Minimal SignalEngine stub for benchmarking. + * Signals store values but don't propagate — we measure diff cost, not signal propagation. + */ +import type { + SignalEngine, + ReactiveSignal, + SignalScope, +} from '../../../src/signals/types' + +let updateCount = 0 + +export function resetUpdateCount(): void { + updateCount = 0 +} + +export function getUpdateCount(): number { + return updateCount +} + +function createBenchSignal(value: T): ReactiveSignal { + let current = value + return { + get(): T { + return current + }, + set(v: T): void { + current = v + updateCount++ + }, + } +} + +export const benchEngine: SignalEngine = { + signal(value: T): ReactiveSignal { + return createBenchSignal(value) + }, + computed(fn: () => T) { + return { get: fn } + }, + effect(_fn: () => void): () => void { + // no-op + return () => {} + }, + batch(fn: () => void): void { + fn() + }, + createScope(): SignalScope { + return { + run(fn: () => T): T { + return fn() + }, + stop(): void {}, + } + }, +} diff --git a/test/benchmarks/reconcile/registryHelper.ts b/test/benchmarks/reconcile/registryHelper.ts new file mode 100644 index 000000000..71f1bff30 --- /dev/null +++ b/test/benchmarks/reconcile/registryHelper.ts @@ -0,0 +1,186 @@ +/** + * Helpers to populate a PathSignalRegistry with tracked paths, + * simulating what happens when selectors access specific state paths. + */ +import { createPathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import type { SignalEngine } from '../../../src/signals/types' +import { + findKeyField, + buildIdentityPath, + getKeyValue, +} from '../../../src/signals/arrayKeys' + +/** + * Walk an object and register all leaf paths as tracked signals. + * Intermediate objects get ensurePrefix (like the tracking proxy does). + * @param state - The state value to walk + * @param parentPath - Dot-separated path prefix + * @param registry - Signal registry to populate + * @returns void + */ +export function registerAllPaths( + state: unknown, + parentPath: string, + registry: PathSignalRegistry, +): void { + if (state === null || typeof state !== 'object') { + if (parentPath) registry.getOrCreate(parentPath, state) + return + } + + if (parentPath) registry.ensurePrefix(parentPath) + + if (Array.isArray(state)) { + for (let i = 0; i < state.length; i++) { + const childPath = parentPath ? `${parentPath}.${i}` : String(i) + registerAllPaths(state[i], childPath, registry) + } + } else { + const keys = Object.keys(state as Record) + for (const key of keys) { + const childPath = parentPath ? `${parentPath}.${key}` : key + registerAllPaths( + (state as Record)[key], + childPath, + registry, + ) + } + } +} + +/** + * Register specific paths as tracked. + * Each path gets a signal created with the value from the state. + * @param state - The state to extract values from + * @param paths - Array of dot-separated paths to register + * @param registry - Signal registry to populate + * @returns void + */ +export function registerPaths( + state: unknown, + paths: string[], + registry: PathSignalRegistry, +): void { + for (const path of paths) { + const value = getValueAtPath(state, path) + // Ensure parent prefixes exist + const parts = path.split('.') + for (let i = 1; i < parts.length; i++) { + registry.ensurePrefix(parts.slice(0, i).join('.')) + } + registry.getOrCreate(path, value) + } +} + +/** + * Register paths for a typical "entity list" selector pattern: + * - all leaf fields of every entity in an array + * - the array's @@keys signal + * @param state - The root state object + * @param arrayPath - Dot-separated path to the entity array + * @param registry - Signal registry to populate + * @returns void + */ +export function registerEntityArrayPaths( + state: Record, + arrayPath: string, + registry: PathSignalRegistry, +): void { + const arr = getValueAtPath(state, arrayPath) as unknown[] + if (!Array.isArray(arr)) return + + // Register array path and @@keys + registry.ensurePrefix(arrayPath) + registry.getOrCreate(`${arrayPath}.@@keys`, arr.length) + registry.getOrCreate(`${arrayPath}.length`, arr.length) + + // Detect key field on first element (same as proxy does) + const keyField = arr.length > 0 ? findKeyField(arr[0]) : undefined + + if (keyField) { + // Set up ArrayMeta so diff uses identity-based matching + registry.setArrayMeta(arrayPath, { keyField, entityMap: new Map() }) + } + + for (let i = 0; i < arr.length; i++) { + const item = arr[i] + if (item !== null && typeof item === 'object' && !Array.isArray(item)) { + // Use identity path if key field detected, else index path + let itemPath: string + if (keyField) { + const kv = getKeyValue(item, keyField) + itemPath = + kv !== undefined + ? buildIdentityPath(arrayPath, keyField, kv) + : `${arrayPath}.${i}` + } else { + itemPath = `${arrayPath}.${i}` + } + registry.ensurePrefix(itemPath) + const keys = Object.keys(item as Record) + for (const key of keys) { + registry.getOrCreate( + `${itemPath}.${key}`, + (item as Record)[key], + ) + } + } + } +} + +/** + * Register paths for a wide-flat object: all leaf values of each top-level key's child object. + * @param state - The root state object + * @param registry - Signal registry to populate + * @returns void + */ +export function registerWideFlatPaths( + state: Record, + registry: PathSignalRegistry, +): void { + const keys = Object.keys(state) + for (const key of keys) { + const childPath = key + const child = state[key] + if (child !== null && typeof child === 'object' && !Array.isArray(child)) { + registry.ensurePrefix(childPath) + const childKeys = Object.keys(child as Record) + for (const ck of childKeys) { + registry.getOrCreate( + `${childPath}.${ck}`, + (child as Record)[ck], + ) + } + } else { + registry.getOrCreate(childPath, child) + } + } +} + +/** + * Register all paths in a deep-nested state tree. + * @param state - The root state object + * @param parentPath - Dot-separated path prefix + * @param registry - Signal registry to populate + * @returns void + */ +export function registerDeepNestedPaths( + state: Record, + parentPath: string, + registry: PathSignalRegistry, +): void { + registerAllPaths(state, parentPath, registry) +} + +function getValueAtPath(state: unknown, path: string): unknown { + const parts = path.split('.') + let current = state + for (const part of parts) { + if (current === null || typeof current !== 'object') return undefined + current = (current as Record)[part] + } + return current +} + +export { createPathSignalRegistry } diff --git a/test/benchmarks/reconcile/run-single.ts b/test/benchmarks/reconcile/run-single.ts new file mode 100644 index 000000000..5c8550cc8 --- /dev/null +++ b/test/benchmarks/reconcile/run-single.ts @@ -0,0 +1,84 @@ +/** + * Single-scenario runner for profiling with pprof-it or --prof. + * Runs one scenario in a tight loop with no table formatting overhead. + * + * Usage: + * npx tsx test/benchmarks/reconcile/run-single.ts entity-shift + * npx pprof-it npx tsx test/benchmarks/reconcile/run-single.ts entity-shift + * npx tsx test/benchmarks/reconcile/run-single.ts entity-shift --iterations 100000 + */ +import { diffAndUpdateSignals } from '../../../src/signals/diff' +import { resetUpdateCount, getUpdateCount } from './benchEngine' +import { createScenarios } from './scenarios' + +const WARMUP = 2000 +const DEFAULT_ITERATIONS = 50000 + +function main() { + const args = process.argv.slice(2) + let scenarioName: string | undefined + let iterations = DEFAULT_ITERATIONS + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--iterations' || args[i] === '-n') { + iterations = parseInt(args[i + 1], 10) + i++ + } else if (!args[i].startsWith('-')) { + scenarioName = args[i] + } + } + + if (!scenarioName) { + const all = createScenarios() + console.error('Usage: run-single.ts ') + console.error(`Available: ${all.map((s) => s.name).join(', ')}`) + process.exit(1) + } + + const scenarios = createScenarios() + const scenario = scenarios.find((s) => s.name === scenarioName) + if (!scenario) { + console.error(`Unknown scenario: ${scenarioName}`) + console.error(`Available: ${scenarios.map((s) => s.name).join(', ')}`) + process.exit(1) + } + + const { forward, reverse, registry } = scenario + + console.log(`Scenario: ${scenario.name}`) + console.log(`Tracked paths: ${scenario.trackedPaths}`) + console.log(`Warmup: ${WARMUP} | Iterations: ${iterations}`) + + // Warmup + for (let i = 0; i < WARMUP; i++) { + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + + // Count signal updates + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + const fwdUpdates = getUpdateCount() + resetUpdateCount() + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + const revUpdates = getUpdateCount() + console.log(`Signal updates: ${fwdUpdates} fwd, ${revUpdates} rev`) + + // Timed run + console.log(`\nStarting ${iterations} iterations...`) + const start = performance.now() + for (let i = 0; i < iterations; i++) { + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const elapsed = performance.now() - start + + console.log(`Total: ${elapsed.toFixed(1)} ms`) + console.log(`Per iteration: ${(elapsed / iterations).toFixed(4)} ms`) + console.log( + `Iterations/sec: ${Math.round(1000 / (elapsed / iterations)).toLocaleString()}`, + ) +} + +main() diff --git a/test/benchmarks/reconcile/runner.ts b/test/benchmarks/reconcile/runner.ts new file mode 100644 index 000000000..8ce3d4b28 --- /dev/null +++ b/test/benchmarks/reconcile/runner.ts @@ -0,0 +1,199 @@ +/** + * Reconcile diff micro-benchmark runner. + * Runs all scenarios (or a specific one) and outputs a results table. + * + * Usage: + * npx tsx test/benchmarks/reconcile/runner.ts + * npx tsx test/benchmarks/reconcile/runner.ts entity-shift + * npx tsx test/benchmarks/reconcile/runner.ts --iterations 50000 + */ +import { diffAndUpdateSignals } from '../../../src/signals/diff' +import { resetUpdateCount, getUpdateCount } from './benchEngine' +import { createScenarios, type BenchmarkScenario } from './scenarios' + +const WARMUP = 1000 +const DEFAULT_ITERATIONS = 10000 + +interface BenchResult { + name: string + description: string + trackedPaths: number + signalUpdatesForward: number + signalUpdatesReverse: number + warmupMs: number + totalMs: number + msPerIter: number + itersPerSec: number +} + +function runScenario( + scenario: BenchmarkScenario, + iterations: number, +): BenchResult { + const { forward, reverse, registry } = scenario + + // Warmup + const warmupStart = performance.now() + for (let i = 0; i < WARMUP; i++) { + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const warmupMs = performance.now() - warmupStart + + // Measure signal updates for one forward+reverse cycle + resetUpdateCount() + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + const signalUpdatesForward = getUpdateCount() + resetUpdateCount() + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + const signalUpdatesReverse = getUpdateCount() + + // Timed run + const start = performance.now() + for (let i = 0; i < iterations; i++) { + diffAndUpdateSignals(forward.prev, forward.next, '', registry) + diffAndUpdateSignals(reverse.prev, reverse.next, '', registry) + } + const totalMs = performance.now() - start + const msPerIter = totalMs / iterations + + return { + name: scenario.name, + description: scenario.description, + trackedPaths: scenario.trackedPaths, + signalUpdatesForward, + signalUpdatesReverse, + warmupMs, + totalMs, + msPerIter, + itersPerSec: 1000 / msPerIter, + } +} + +function formatTable(results: BenchResult[]): string { + const cols = [ + { key: 'name' as const, header: 'Scenario', width: 24 }, + { + key: 'trackedPaths' as const, + header: 'Tracked', + width: 8, + align: 'right' as const, + }, + { + key: 'signalUpdatesForward' as const, + header: 'Fwd Upd', + width: 9, + align: 'right' as const, + }, + { + key: 'signalUpdatesReverse' as const, + header: 'Rev Upd', + width: 9, + align: 'right' as const, + }, + { + key: 'msPerIter' as const, + header: 'ms/iter', + width: 10, + align: 'right' as const, + }, + { + key: 'itersPerSec' as const, + header: 'iter/s', + width: 10, + align: 'right' as const, + }, + { + key: 'totalMs' as const, + header: 'Total ms', + width: 10, + align: 'right' as const, + }, + ] + + function pad(s: string, w: number, align: 'left' | 'right' = 'left'): string { + if (s.length >= w) return s.slice(0, w) + return align === 'right' ? s.padStart(w) : s.padEnd(w) + } + + function formatVal(key: string, val: unknown): string { + if (typeof val === 'number') { + if (key === 'msPerIter') return val.toFixed(4) + if (key === 'itersPerSec') return Math.round(val).toLocaleString() + if (key === 'totalMs') return val.toFixed(1) + return String(val) + } + return String(val) + } + + const lines: string[] = [] + // Header + const header = cols.map((c) => pad(c.header, c.width, c.align)).join(' | ') + lines.push(header) + lines.push(cols.map((c) => '-'.repeat(c.width)).join('-+-')) + + // Rows + for (const r of results) { + const row = cols + .map((c) => pad(formatVal(c.key, r[c.key]), c.width, c.align)) + .join(' | ') + lines.push(row) + } + + return lines.join('\n') +} + +// ---- Main ---- +function main() { + const args = process.argv.slice(2) + let filterName: string | undefined + let iterations = DEFAULT_ITERATIONS + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--iterations' || args[i] === '-n') { + iterations = parseInt(args[i + 1], 10) + i++ + } else if (!args[i].startsWith('-')) { + filterName = args[i] + } + } + + console.log(`\nReconcile Diff Benchmark`) + console.log( + `Warmup: ${WARMUP} | Iterations: ${iterations} (forward+reverse per iter)\n`, + ) + + let scenarios = createScenarios() + if (filterName) { + scenarios = scenarios.filter((s) => s.name === filterName) + if (scenarios.length === 0) { + console.error(`Unknown scenario: ${filterName}`) + console.error( + `Available: ${createScenarios() + .map((s) => s.name) + .join(', ')}`, + ) + process.exit(1) + } + } + + const results: BenchResult[] = [] + for (const scenario of scenarios) { + process.stdout.write(` Running ${scenario.name}...`) + const result = runScenario(scenario, iterations) + results.push(result) + console.log(` ${result.msPerIter.toFixed(4)} ms/iter`) + } + + console.log(`\n${formatTable(results)}\n`) + + // Descriptions + console.log('Scenario descriptions:') + for (const r of results) { + console.log(` ${r.name}: ${r.description}`) + } + console.log() +} + +main() diff --git a/test/benchmarks/reconcile/scenarios.ts b/test/benchmarks/reconcile/scenarios.ts new file mode 100644 index 000000000..92489aa47 --- /dev/null +++ b/test/benchmarks/reconcile/scenarios.ts @@ -0,0 +1,139 @@ +/** + * Benchmark scenario definitions. + * Each scenario constructs frozen state with structural sharing, + * sets up a registry with appropriate tracked paths, and provides + * forward/reverse state pairs for diffing. + */ +import type { PathSignalRegistry } from '../../../src/signals/pathSignalRegistry' +import { benchEngine } from './benchEngine' +import { + createPathSignalRegistry, + registerEntityArrayPaths, + registerAllPaths, + registerWideFlatPaths, +} from './registryHelper' +import { + buildEntityShift, + buildEntitySingleUpdate, + buildEntityBulkUpdate, + buildDeepNested, + buildWideFlat, + buildArrayReorder, + buildArrayAppend, +} from './stateBuilders' + +export interface BenchmarkScenario { + name: string + description: string + forward: { prev: unknown; next: unknown } + reverse: { prev: unknown; next: unknown } + registry: PathSignalRegistry + trackedPaths: number +} + +function makeScenario( + name: string, + description: string, + forward: { prev: unknown; next: unknown }, + reverse: { prev: unknown; next: unknown }, + setupRegistry: (registry: PathSignalRegistry, state: unknown) => void, +): BenchmarkScenario { + const registry = createPathSignalRegistry(benchEngine) + setupRegistry(registry, forward.prev) + return { + name, + description, + forward, + reverse, + registry, + trackedPaths: registry.size(), + } +} + +export function createScenarios(): BenchmarkScenario[] { + const entityShift = buildEntityShift(1000) + const entitySingle = buildEntitySingleUpdate(1000) + const entityBulk = buildEntityBulkUpdate(1000) + const deepNested = buildDeepNested(5, 5) + const wideFlat = buildWideFlat(500, 5) + const arrayReorder = buildArrayReorder(100) + const arrayAppend = buildArrayAppend(1000, 10) + + return [ + makeScenario( + 'entity-shift', + 'Remove first of 1000 entities (flat array), all shift by 1', + entityShift.forward, + entityShift.reverse, + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), + ), + makeScenario( + 'entity-single-update', + 'Update 1 entity name in 1000 (structural sharing)', + entitySingle.forward, + entitySingle.reverse, + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), + ), + makeScenario( + 'entity-bulk-update', + 'Update 10% of 1000 entities (every 10th, structural sharing)', + entityBulk.forward, + entityBulk.reverse, + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'entities', + reg, + ), + ), + makeScenario( + 'deep-nested', + '5-level deep (breadth=5), change one leaf', + deepNested.forward, + deepNested.reverse, + (reg, state) => registerAllPaths(state, '', reg), + ), + makeScenario( + 'wide-flat', + '500 top-level keys (2 fields each), change 5', + wideFlat.forward, + wideFlat.reverse, + (reg, state) => + registerWideFlatPaths(state as Record, reg), + ), + makeScenario( + 'array-reorder', + 'Reverse 100-element entity array', + arrayReorder.forward, + arrayReorder.reverse, + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'items', + reg, + ), + ), + makeScenario( + 'array-append', + 'Append 10 items to 1000-element array', + arrayAppend.forward, + arrayAppend.reverse, + (reg, state) => + registerEntityArrayPaths( + state as Record, + 'items', + reg, + ), + ), + ] +} diff --git a/test/benchmarks/reconcile/stateBuilders.ts b/test/benchmarks/reconcile/stateBuilders.ts new file mode 100644 index 000000000..eb749b232 --- /dev/null +++ b/test/benchmarks/reconcile/stateBuilders.ts @@ -0,0 +1,186 @@ +/** + * State construction helpers for benchmark scenarios. + * All state is deeply frozen to simulate Immer-produced Redux state. + * Forward/reverse pairs share structure to simulate Immer's structural sharing. + */ + +export function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) deepFreeze(obj[i]) + } else { + const keys = Object.keys(obj) + for (let i = 0; i < keys.length; i++) { + deepFreeze((obj as Record)[keys[i]]) + } + } + return obj +} + +interface Entity { + id: string + name: string + email: string + status: string + score: number +} + +function makeEntity(i: number): Entity { + return { + id: `entity-${i}`, + name: `User ${i}`, + email: `user${i}@example.com`, + status: i % 3 === 0 ? 'active' : i % 3 === 1 ? 'inactive' : 'pending', + score: i * 10, + } +} + +// --------------- Scenario: entity-shift --------------- +// Remove first entity from a flat array of 1000, shifting all others +export function buildEntityShift(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + // Remove first entity — everything shifts + const shifted = entities.slice(1) + const next = deepFreeze({ entities: shifted }) + + // Reverse: put it back (go from shifted → base) + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: entity-single-update --------------- +// Update one entity's name in a flat array of 1000 +export function buildEntitySingleUpdate(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + // Structural sharing: new array with only index 500 changed + const midIdx = Math.floor(count / 2) + const updatedEntities = [...entities] + updatedEntities[midIdx] = { ...entities[midIdx], name: 'UPDATED' } + // Freeze only the new objects, reuse frozen originals at other indices + Object.freeze(updatedEntities[midIdx]) + Object.freeze(updatedEntities) + const next = deepFreeze({ entities: updatedEntities }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: entity-bulk-update --------------- +// Update 10% of entities (every 10th) +export function buildEntityBulkUpdate(count = 1000) { + const entities: Entity[] = [] + for (let i = 0; i < count; i++) entities.push(makeEntity(i)) + const base = deepFreeze({ entities }) + + const updatedEntities = [...entities] + for (let i = 0; i < count; i += 10) { + updatedEntities[i] = { ...entities[i], score: entities[i].score + 1 } + Object.freeze(updatedEntities[i]) + } + Object.freeze(updatedEntities) + const next = deepFreeze({ entities: updatedEntities }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: deep-nested --------------- +// 5-level deep object, change one leaf +export function buildDeepNested(depth = 5, breadth = 5) { + function buildLevel(d: number): Record { + const obj: Record = {} + if (d === 0) { + for (let i = 0; i < breadth; i++) { + obj[`leaf${i}`] = `value-${i}` + } + } else { + for (let i = 0; i < breadth; i++) { + obj[`level${i}`] = buildLevel(d - 1) + } + } + return obj + } + + const base = deepFreeze(buildLevel(depth)) + + // Change one deep leaf with structural sharing along the path + // Path: level0.level0.level0.level0.level0.leaf0 + function changeDeepLeaf( + state: Record, + d: number, + ): Record { + if (d === 0) { + return { ...state, leaf0: 'CHANGED' } + } + return { + ...state, + level0: changeDeepLeaf(state.level0 as Record, d - 1), + } + } + + const next = deepFreeze(changeDeepLeaf(base, depth)) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: wide-flat --------------- +// Single object with many top-level keys, change a few +export function buildWideFlat(width = 500, changes = 5) { + const obj: Record = {} + for (let i = 0; i < width; i++) { + obj[`key${i}`] = { value: i, label: `item-${i}` } + } + const base = deepFreeze(obj) + + const next: Record = { ...obj } + const changeIndices: number[] = [] + for (let c = 0; c < changes; c++) { + const idx = Math.floor((width / changes) * c) + changeIndices.push(idx) + next[`key${idx}`] = { value: idx + 1000, label: `item-${idx}-updated` } + Object.freeze(next[`key${idx}`]) + } + Object.freeze(next) + + return { + forward: { prev: base, next: next as Record }, + reverse: { prev: next as Record, next: base }, + meta: { changeIndices }, + } +} + +// --------------- Scenario: array-reorder --------------- +// Reverse the order of a 100-element array +export function buildArrayReorder(count = 100) { + const items: Entity[] = [] + for (let i = 0; i < count; i++) items.push(makeEntity(i)) + const base = deepFreeze({ items }) + + const reversed = [...items].reverse() + Object.freeze(reversed) + const next = deepFreeze({ items: reversed }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} + +// --------------- Scenario: array-append --------------- +// Append 10 items to a 1000-element array (with structural sharing for unchanged items) +export function buildArrayAppend(count = 1000, appendCount = 10) { + const items: Entity[] = [] + for (let i = 0; i < count; i++) items.push(makeEntity(i)) + const base = deepFreeze({ items }) + + const newItems = [...items] + for (let i = 0; i < appendCount; i++) { + newItems.push(makeEntity(count + i)) + Object.freeze(newItems[count + i]) + } + Object.freeze(newItems) + const next = deepFreeze({ items: newItems }) + + return { forward: { prev: base, next }, reverse: { prev: next, next: base } } +} diff --git a/test/entries/signals.ts b/test/entries/signals.ts new file mode 100644 index 000000000..9f4825a2e --- /dev/null +++ b/test/entries/signals.ts @@ -0,0 +1,21 @@ +/** + * Test entry point that presents the signal implementation under the + * stock public names. + * + * `vitest.config.mts` aliases the bare specifier `react-redux` to one of + * these entries. Test files import `{ Provider, useSelector }` from + * `'react-redux'` and never learn which implementation they got, so the + * SAME spec file validates both — no copy-paste, and no need to edit + * `src/exports.ts` to point the library at the signal versions. + * + * Explicit local exports shadow `export *`, so this file is correct + * regardless of what `src/exports.ts` currently exports under these + * names. + */ +export * from '../../src/index' + +export { + SignalProvider as Provider, + useSignalSelector as useSelector, + createSignalSelectorHook as createSelectorHook, +} from '../../src/signals' diff --git a/test/entries/stock.ts b/test/entries/stock.ts new file mode 100644 index 000000000..2a9f589a1 --- /dev/null +++ b/test/entries/stock.ts @@ -0,0 +1,13 @@ +/** + * Test entry point that presents the stock implementation under its own + * names. See `./signals.ts` for how the two are selected. + * + * This exists so the stock run is pinned to the real stock hooks even + * while `src/exports.ts` carries the benchmark override that points + * `Provider`/`useSelector` at the signal versions. Explicit local + * exports shadow `export *`. + */ +export * from '../../src/index' + +export { default as Provider } from '../../src/components/Provider' +export { useSelector, createSelectorHook } from '../../src/hooks/useSelector' diff --git a/test/hooks/useSelector.spec.tsx b/test/hooks/useSelector.spec.tsx index d802e8e97..5d04c65a7 100644 --- a/test/hooks/useSelector.spec.tsx +++ b/test/hooks/useSelector.spec.tsx @@ -48,6 +48,17 @@ function ProviderMock = AnyAction, S = unknown>({ const IS_REACT_18 = React.version.startsWith('18') +/** + * Which implementation `react-redux` was aliased to (see + * `vitest.config.mts` and `test/entries/`). Only a couple of tests here + * need to know: the ones that assert on stock's `Subscription` chain, + * which the signal implementation does not use at all — it propagates + * through the Provider's single store subscription and the signal graph + * instead, so no per-component listener ever joins that chain. Those + * tests assert a mechanism, not observable behavior. + */ +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + describe('React', () => { describe('hooks', () => { describe('useSelector', () => { @@ -121,10 +132,16 @@ describe('React', () => { describe('lifecycle interactions', () => { it('always uses the latest state', () => { - const store = createStore((c: number = 1): number => c + 1, -1) + const store = createStore( + ({ c }: { c: number } = { c: 1 }) => ({ c: c + 1 }), + { c: -1 }, + ) const Comp = () => { - const selector = useCallback((c: number): number => c + 1, []) + const selector = useCallback( + ({ c }: { c: number }): number => c + 1, + [], + ) const value = useSelector(selector) renderedItems.push(value) return
@@ -145,7 +162,7 @@ describe('React', () => { expect(renderedItems).toEqual([1, 2]) }) - it('subscribes to the store synchronously', () => { + it.skipIf(IS_SIGNALS)('subscribes to the store synchronously', () => { let appSubscription: Subscription | null = null const Child = () => { @@ -176,36 +193,39 @@ describe('React', () => { expect(appSubscription!.getListeners().get().length).toBe(2) }) - it('unsubscribes when the component is unmounted', () => { - let appSubscription: Subscription | null = null + it.skipIf(IS_SIGNALS)( + 'unsubscribes when the component is unmounted', + () => { + let appSubscription: Subscription | null = null - const Parent = () => { - const contextVal = useContext(ReactReduxContext) - appSubscription = contextVal && contextVal.subscription - const count = useNormalSelector((s) => s.count) - return count === 0 ? : null - } + const Parent = () => { + const contextVal = useContext(ReactReduxContext) + appSubscription = contextVal && contextVal.subscription + const count = useNormalSelector((s) => s.count) + return count === 0 ? : null + } - const Child = () => { - const count = useNormalSelector((s) => s.count) - return
{count}
- } + const Child = () => { + const count = useNormalSelector((s) => s.count) + return
{count}
+ } - rtl.render( - - - , - ) - // Parent + 1 child component - expect(appSubscription!.getListeners().get().length).toBe(2) + rtl.render( + + + , + ) + // Parent + 1 child component + expect(appSubscription!.getListeners().get().length).toBe(2) - rtl.act(() => { - normalStore.dispatch({ type: '' }) - }) + rtl.act(() => { + normalStore.dispatch({ type: '' }) + }) - // Parent component only - expect(appSubscription!.getListeners().get().length).toBe(1) - }) + // Parent component only + expect(appSubscription!.getListeners().get().length).toBe(1) + }, + ) it('notices store updates between render and store subscription effect', () => { const Child = ({ count }: { count: number }) => { @@ -251,10 +271,13 @@ describe('React', () => { }) it('works properly with memoized selector with dispatch in Child useLayoutEffect', () => { - const store = createStore((c: number = 1): number => c + 1, -1) + const store = createStore( + ({ c }: { c: number } = { c: 1 }) => ({ c: c + 1 }), + { c: -1 }, + ) const Comp = () => { - const selector = useCallback((c: number): number => c, []) + const selector = useCallback(({ c }: { c: number }): number => c, []) const count = useSelector(selector) renderedItems.push(count) return @@ -523,7 +546,7 @@ describe('React', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) const Comp = () => { - const result = useSelector((count: number) => { + const result = useSelector(({ count }: { count: number }) => { if (count > 0) { // console.log('Throwing error') throw new Error('Panic!') @@ -535,7 +558,11 @@ describe('React', () => { return
{result}
} - const store = createStore((count: number = -1): number => count + 1) + const store = createStore( + ({ count }: { count: number } = { count: -1 }) => ({ + count: count + 1, + }), + ) const App = () => ( @@ -668,68 +695,90 @@ describe('React', () => { expect(renderedItems[0]).toBe(renderedItems[1]) }) - it('should have linear or better unsubscribe time, not quadratic', () => { - const reducer = (state: number = 0, action: any) => - action.type === 'INC' ? state + 1 : state - const store = createStore(reducer) - const increment = () => ({ type: 'INC' }) - - const numChildren = 100000 - - function App() { - useSelector((s: number) => s) - const dispatch = useDispatch() - - const [children, setChildren] = useState(numChildren) - - const toggleChildren = () => - setChildren((c) => (c ? 0 : numChildren)) - - return ( -
- - - {[...Array(children).keys()].map((i) => ( - - ))} -
- ) - } - - function Child() { - useSelector((s: number) => s) - // Deliberately do not return any DOM here - we want to isolate the cost of - // unsubscribing, and tearing down thousands of JSDOM nodes is expensive and irrelevant - return null - } + it( + 'should have linear or better unsubscribe time, not quadratic', + // Six mounts of 10K components dominate the runtime and slow down + // several-fold when the suite runs in parallel; the unmount ratio + // below is what's actually asserted. + { timeout: 30_000 }, + () => { + const reducer = (state: { n: number } = { n: 0 }, action: any) => + action.type === 'INC' ? { n: state.n + 1 } : state + + // Neither child returns DOM: the point is the cost of + // unsubscribing, and tearing down thousands of JSDOM nodes + // would swamp it. + function SubscribedChild() { + useSelector((s: number) => s) + return null + } + function PlainChild() { + return null + } - const { getByText } = rtl.render( - - - , - ) + // Mount `numChildren` children, then unmount them all in one + // commit and return how long that commit took. + function measureUnmount( + numChildren: number, + Child: () => null, + ): number { + const store = createStore(reducer) + + function App() { + useSelector((s: { n: number }) => s.n) + const [children, setChildren] = useState(numChildren) + return ( +
+ + {[...Array(children).keys()].map((i) => ( + + ))} +
+ ) + } - const timeBefore = Date.now() + const { getByText, unmount } = rtl.render( + + + , + ) + const button = getByText('Toggle Children') + + const timeBefore = performance.now() + rtl.act(() => { + rtl.fireEvent.click(button) + }) + const elapsed = performance.now() - timeBefore + + unmount() + return elapsed + } - const button = getByText('Toggle Children') - rtl.act(() => { - rtl.fireEvent.click(button) - }) + // Unsubscribing used to cost O(N) per component (array scan in + // Subscription), so unmounting N subscribed children was + // quadratic. An absolute time budget can't detect that reliably: + // it depends on the machine and on how many other test workers + // are competing for the CPU. So compare against a baseline + // instead: the same commit tearing down the same number of + // children that never subscribed. That cancels React's own + // teardown cost and any CPU contention, leaving the cost the + // subscriptions add. Measured at 10K children: linear + // unsubscribe is ~3x the baseline, the old quadratic behavior + // ~40x. Min of three runs filters GC pauses and cold-JIT noise. + const N = 10_000 + const minOf = (fn: () => number) => { + let best = Infinity + for (let i = 0; i < 3; i++) best = Math.min(best, fn()) + return best + } + const plain = minOf(() => measureUnmount(N, PlainChild)) + const subscribed = minOf(() => measureUnmount(N, SubscribedChild)) - const timeAfter = Date.now() - const elapsedTime = timeAfter - timeBefore - - // Seeing an unexpected variation in elapsed time between React 18 and React 17 + the compat entry point. - // With 18, I see around 75ms with correct implementation on my machine, with 100K items. - // With 17 + compat, the same correct impl takes about 4200-5000ms. - // With the quadratic behavior, this is at least 13000ms (or worse!) under 18, and 22000ms+ with 17. - // The 13000ms time for 18 stays the same if I use the shim, so it must be a 17 vs 18 difference somehow, - // although I can't imagine why, and if I remove the `useSelector` calls both tests drop to ~50ms. - // So, we'll modify our expectations here depending on whether this is an 18 or 17 compat test, - // and give some buffer time to allow for variations in test machines. - const expectedMaxUnmountTime = IS_REACT_18 || IS_REACT_19 ? 500 : 7000 - expect(elapsedTime).toBeLessThan(expectedMaxUnmountTime) - }) + expect(subscribed / plain).toBeLessThan(10) + }, + ) it('keeps working when used inside a Suspense', async () => { let result: number | undefined diff --git a/test/signals/arrayMethodOverrides.spec.ts b/test/signals/arrayMethodOverrides.spec.ts new file mode 100644 index 000000000..e34c5d8f4 --- /dev/null +++ b/test/signals/arrayMethodOverrides.spec.ts @@ -0,0 +1,854 @@ +import { describe, test, expect, vi } from 'vitest' +import { alienEngine } from '@internal/signals/engine' +import { createPathSignalRegistry } from '@internal/signals/pathSignalRegistry' +import { + createTrackingProxy, + type ProxyCache, +} from '@internal/signals/trackingProxy' +import { getProxyPath, unwrap } from '@internal/signals/trackingProxy' + +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object') deepFreeze(val) + } + return obj +} + +function setup(state: T) { + const registry = createPathSignalRegistry(alienEngine) + const cache: ProxyCache = new WeakMap() + const proxy = createTrackingProxy(state, '', registry, cache) + return { registry, cache, proxy } +} + +describe('Array method overrides on tracking proxy', () => { + // Test data factory — mimics Immer test structure + const createTestData = () => + deepFreeze({ + items: [ + { id: 1, value: 10, nested: { count: 1 } }, + { id: 2, value: 20, nested: { count: 2 } }, + { id: 3, value: 30, nested: { count: 3 } }, + { id: 4, value: 40, nested: { count: 4 } }, + { id: 5, value: 50, nested: { count: 5 } }, + ], + other: { data: 'test' }, + }) + + describe('find()', () => { + test('returns the raw found item with an identity dependency', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const found = proxy.items.find((item) => item.id === 3) + expect(found).toBe(state.items[2]) + expect(getProxyPath(found)).toBeUndefined() + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:2}')).toBe(false) + }) + + test('returns undefined when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.find((item) => item.id === 999) + expect(found).toBeUndefined() + }) + + test('callback receives scan recorder, unwrap resolves raw element', () => { + const state = createTestData() + const { proxy } = setup(state) + const unwrapped: unknown[] = [] + let callCount = 0 + proxy.items.find((item) => { + callCount++ + // Not a tracking proxy (no path registered) + expect(getProxyPath(item)).toBeUndefined() + // unwrap resolves to the raw frozen element + unwrapped.push(unwrap(item)) + return false // never match, scan all + }) + expect(callCount).toBe(5) + for (let i = 0; i < 5; i++) { + expect(unwrapped[i]).toBe(state.items[i]) + expect(Object.isFrozen(unwrapped[i])).toBe(true) + } + }) + + test('only registers signals for the found element, not all scanned elements', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // find item with id === 3 (index 2) + const found = proxy.items.find((item) => item.id === 3) + // Access a property to register a signal + const _val = found!.value + + // Should have a signal for the found item's value, NOT for all items + // With identity-based tracking, the path would be items.{id:3}.value + expect(registry.size()).toBeGreaterThan(0) + + // The key assertion: we should NOT have signals for items that were + // only scanned (not returned). Check that items 1, 2, 4, 5 don't + // have signals registered. + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:2}.value')).toBe(false) + expect(registry.has('items.{id:4}.value')).toBe(false) + expect(registry.has('items.{id:5}.value')).toBe(false) + }) + + test('callback receives correct index and array arguments', () => { + const state = createTestData() + const { proxy } = setup(state) + const indices: number[] = [] + let receivedArray: unknown + + proxy.items.find((item, index, arr) => { + indices.push(index) + receivedArray = arr + return item.id === 2 + }) + + expect(indices).toEqual([0, 1]) // stops after finding id:2 + // Array argument should be the raw frozen array + expect(receivedArray).toBe(state.items) + }) + + test('found element properties are accessible through proxy', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.find((item) => item.id === 2)! + expect(found.id).toBe(2) + expect(found.value).toBe(20) + expect(found.nested.count).toBe(2) + }) + + test('works with primitive arrays', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + const found = proxy.nums.find((n) => n > 25) + expect(found).toBe(30) + }) + }) + + describe('findLast()', () => { + test('returns the raw last matching item with an identity dependency', () => { + const state = deepFreeze({ + items: [ + { id: 1, type: 'A' }, + { id: 2, type: 'B' }, + { id: 3, type: 'A' }, + ], + }) + const { proxy, registry } = setup(state) + const found = proxy.items.findLast((item) => item.type === 'A') + expect(found).toBe(state.items[2]) + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:1}')).toBe(false) + }) + + test('returns undefined when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const found = proxy.items.findLast((item) => item.id === 999) + expect(found).toBeUndefined() + }) + + test('searches from end', () => { + const state = createTestData() + const { proxy } = setup(state) + const indices: number[] = [] + proxy.items.findLast((item, index) => { + indices.push(index) + return item.id === 3 + }) + // Should search backwards: 4, 3, 2 — stops at index 2 + expect(indices).toEqual([4, 3, 2]) + }) + }) + + describe('findIndex()', () => { + test('returns index of found item', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findIndex((item) => item.id === 3) + expect(index).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findIndex((item) => item.id === 999) + expect(index).toBe(-1) + }) + + test('does not register any element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + proxy.items.findIndex((item) => item.id === 3) + // findIndex returns a primitive — no element signals should be created + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:3}.value')).toBe(false) + }) + + test('callback receives scan recorder, unwrap resolves raw element', () => { + const state = createTestData() + const { proxy } = setup(state) + let i = 0 + proxy.items.findIndex((item) => { + expect(getProxyPath(item)).toBeUndefined() + expect(unwrap(item)).toBe(state.items[i]) + i++ + return false + }) + expect(i).toBe(5) + }) + }) + + describe('findLastIndex()', () => { + test('returns last matching index', () => { + const state = deepFreeze({ + items: [ + { id: 1, type: 'A' }, + { id: 2, type: 'B' }, + { id: 3, type: 'A' }, + ], + }) + const { proxy } = setup(state) + const index = proxy.items.findLastIndex((item) => item.type === 'A') + expect(index).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = createTestData() + const { proxy } = setup(state) + const index = proxy.items.findLastIndex((item) => item.id === 999) + expect(index).toBe(-1) + }) + }) + + describe('filter()', () => { + test('returns the raw matching items', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.value > 25) + expect(filtered).toEqual([state.items[2], state.items[3], state.items[4]]) + expect(filtered[0]).toBe(state.items[2]) + expect(getProxyPath(filtered[0])).toBeUndefined() + }) + + test('callback receives scan recorder, not tracking proxies', () => { + const state = createTestData() + const { proxy } = setup(state) + const unwrapped: unknown[] = [] + proxy.items.filter((item) => { + expect(getProxyPath(item)).toBeUndefined() + unwrapped.push(unwrap(item)) + return false + }) + expect(unwrapped).toEqual([...state.items]) + }) + + test('registers identity signals for matching elements only', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const filtered = proxy.items.filter((item) => item.id === 3) + // Reads on the raw result are not tracked; the element identity is + expect(filtered[0].value).toBe(30) + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:3}.value')).toBe(false) + expect(registry.has('items.{id:1}')).toBe(false) + expect(registry.has('items.{id:2}')).toBe(false) + }) + + test('filtered items give access to nested properties', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.id > 3) + expect(filtered[0].nested.count).toBe(4) + expect(filtered[1].nested.count).toBe(5) + }) + + test('multiple filters on same array work correctly', () => { + const state = createTestData() + const { proxy } = setup(state) + const high = proxy.items.filter((item) => item.value > 30) + const low = proxy.items.filter((item) => item.value < 30) + expect(high).toHaveLength(2) + expect(low).toHaveLength(2) + }) + + test('returns empty array when nothing matches', () => { + const state = createTestData() + const { proxy } = setup(state) + const filtered = proxy.items.filter((item) => item.id > 100) + expect(filtered).toHaveLength(0) + }) + + test('callback receives correct index and array arguments', () => { + const state = createTestData() + const { proxy } = setup(state) + const seen: number[] = [] + proxy.items.filter((_item, index, arr) => { + seen.push(index) + expect(arr).toBe(state.items) + return false + }) + expect(seen).toEqual([0, 1, 2, 3, 4]) + }) + }) + + describe('slice()', () => { + test('returns the raw sliced items', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(1, 3) + expect(sliced).toEqual([state.items[1], state.items[2]]) + expect(sliced[0]).toBe(state.items[1]) + expect(getProxyPath(sliced[0])).toBeUndefined() + }) + + test('slice with no arguments returns a raw copy of all items', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice() + expect(sliced).toEqual([...state.items]) + expect(sliced).not.toBe(state.items) + }) + + test('slice with negative indices', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-2) + expect(sliced).toHaveLength(2) + expect(sliced[0].id).toBe(4) + expect(sliced[1].id).toBe(5) + }) + + test('slice with negative start and end', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-3, -1) + expect(sliced).toHaveLength(2) + expect(sliced[0].id).toBe(3) + expect(sliced[1].id).toBe(4) + }) + + test('out-of-bounds indices are clamped', () => { + const state = createTestData() + const { proxy } = setup(state) + const sliced = proxy.items.slice(-100, 100) + expect(sliced).toHaveLength(5) + }) + + test('registers identity signals for sliced elements only', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + proxy.items.slice(2, 3) // just item at index 2 (id: 3) + + expect(registry.has('items.{id:3}')).toBe(true) + expect(registry.has('items.{id:1}')).toBe(false) + expect(registry.has('items.{id:2}')).toBe(false) + }) + }) + + describe('some()', () => { + test('returns true when predicate matches', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.some((item) => item.value > 40)).toBe(true) + }) + + test('returns false when no match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.some((item) => item.value > 100)).toBe(false) + }) + + test('short-circuits on first match', () => { + const state = createTestData() + const { proxy } = setup(state) + let callCount = 0 + proxy.items.some((item) => { + callCount++ + return item.id === 2 + }) + expect(callCount).toBe(2) // stops after finding id:2 at index 1 + }) + + test('callback receives raw values', () => { + const state = createTestData() + const { proxy } = setup(state) + proxy.items.some((item) => { + expect(getProxyPath(item)).toBeUndefined() + return false + }) + }) + + test('does not register element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + proxy.items.some((item) => item.id === 3) + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:3}.value')).toBe(false) + }) + }) + + describe('every()', () => { + test('returns true when all match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.every((item) => item.value > 0)).toBe(true) + }) + + test('returns false when some do not match', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.every((item) => item.value > 25)).toBe(false) + }) + + test('short-circuits on first failure', () => { + const state = createTestData() + const { proxy } = setup(state) + let callCount = 0 + proxy.items.every((item) => { + callCount++ + return item.value < 25 + }) + // items: 10, 20, 30 — fails at index 2 (value 30) + expect(callCount).toBe(3) + }) + }) + + describe('includes()', () => { + test('returns true for primitive values', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(30)).toBe(true) + }) + + test('returns false for missing primitive', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(99)).toBe(false) + }) + + test('works with fromIndex', () => { + const state = deepFreeze({ nums: [10, 20, 30, 20, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.includes(20, 2)).toBe(true) // finds the 20 at index 3 + expect(proxy.nums.includes(10, 1)).toBe(false) // 10 is before index 1 + }) + }) + + describe('indexOf()', () => { + test('returns index of primitive value', () => { + const state = deepFreeze({ nums: [10, 20, 30, 40, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(30)).toBe(2) + }) + + test('returns -1 when not found', () => { + const state = deepFreeze({ nums: [10, 20, 30] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(99)).toBe(-1) + }) + + test('with fromIndex parameter', () => { + const state = deepFreeze({ nums: [1, 2, 3, 2, 5] }) + const { proxy } = setup(state) + expect(proxy.nums.indexOf(2)).toBe(1) + expect(proxy.nums.indexOf(2, 2)).toBe(3) + }) + }) + + describe('lastIndexOf()', () => { + test('returns last index of primitive value', () => { + const state = deepFreeze({ nums: [10, 20, 30, 20, 50] }) + const { proxy } = setup(state) + expect(proxy.nums.lastIndexOf(20)).toBe(3) + }) + + test('returns -1 when not found', () => { + const state = deepFreeze({ nums: [10, 20, 30] }) + const { proxy } = setup(state) + expect(proxy.nums.lastIndexOf(99)).toBe(-1) + }) + }) + + describe('join()', () => { + test('returns joined string with separator', () => { + const state = deepFreeze({ items: [1, 2, 3, 4, 5] }) + const { proxy } = setup(state) + expect(proxy.items.join(',')).toBe('1,2,3,4,5') + }) + + test('join with custom separator', () => { + const state = deepFreeze({ items: ['a', 'b', 'c'] }) + const { proxy } = setup(state) + expect(proxy.items.join(' - ')).toBe('a - b - c') + }) + + test('join with no separator uses comma', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + expect(proxy.items.join()).toBe('1,2,3') + }) + }) + + describe('toString()', () => { + test('returns string representation', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + expect(proxy.items.toString()).toBe('1,2,3') + }) + }) + + describe('toLocaleString()', () => { + test('returns locale string', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + // Just verify it returns a string without error + expect(typeof proxy.items.toLocaleString()).toBe('string') + }) + }) + + describe('concat()', () => { + test('returns new array with concatenated items (raw values)', () => { + const state = deepFreeze({ items: [1, 2, 3] }) + const { proxy } = setup(state) + const result = proxy.items.concat([4, 5]) + expect(result).toEqual([1, 2, 3, 4, 5]) + }) + + test('concat with no arguments creates shallow copy', () => { + const state = createTestData() + const { proxy } = setup(state) + const copy = proxy.items.concat() + expect(copy).toHaveLength(5) + expect(copy[0].id).toBe(1) + // Result elements are raw values, not proxies + expect(getProxyPath(copy[0])).toBeUndefined() + }) + + test('concat with multiple arrays', () => { + const state = deepFreeze({ items: [1, 2] }) + const { proxy } = setup(state) + const result = proxy.items.concat([3, 4], [5, 6]) + expect(result).toEqual([1, 2, 3, 4, 5, 6]) + }) + }) + + describe('flat()', () => { + test('flattens nested arrays', () => { + const state = deepFreeze({ items: [[1, 2], [3, 4], [5]] }) + const { proxy } = setup(state) + const result = proxy.items.flat() + expect(result).toEqual([1, 2, 3, 4, 5]) + }) + + test('flat with depth', () => { + const state = deepFreeze({ items: [[[1, 2]], [[3, 4]]] }) + const { proxy } = setup(state) + expect(proxy.items.flat(1)).toEqual([ + [1, 2], + [3, 4], + ]) + expect(proxy.items.flat(2)).toEqual([1, 2, 3, 4]) + }) + }) + + describe('map()', () => { + test('primitive results register a column signal, not element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const mapped = proxy.items.map((item) => item.value * 2) + expect(mapped).toEqual([20, 40, 60, 80, 100]) + expect(registry.has('items.{*}.value')).toBe(true) + expect(registry.has('items.{id:1}.value')).toBe(false) + expect(registry.has('items.{id:1}')).toBe(false) + }) + + test('identity mapping returns raw elements with identity dependencies', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const mapped = proxy.items.map((item) => item) + expect(mapped).toEqual([...state.items]) + expect(mapped[0]).toBe(state.items[0]) + for (let id = 1; id <= 5; id++) { + expect(registry.has(`items.{id:${id}}`)).toBe(true) + } + }) + + test('tracking proxies returned from elsewhere come back raw with a dependency on their container', () => { + const state = deepFreeze({ + ids: [2, 1], + entities: { + 1: { id: 1, name: 'one' }, + 2: { id: 2, name: 'two' }, + } as Record, + }) + const { proxy, registry } = setup(state) + const all = proxy.ids.map((id) => proxy.entities[id]) + expect(all).toEqual([state.entities[2], state.entities[1]]) + expect(all[0]).toBe(state.entities[2]) + expect(getProxyPath(all[0])).toBeUndefined() + // Mapping the collection depends on the container as a whole, not on + // each element: the container is replaced whenever any element is. + expect(registry.has('entities')).toBe(true) + expect(registry.has('entities.2')).toBe(false) + expect(registry.has('entities.1')).toBe(false) + // Primitive ids fall back to the coarse array signal + expect(registry.has('ids')).toBe(true) + }) + + test('fresh objects that embed the element get the raw element, not the recorder', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const calls: number[] = [] + const mapped = proxy.items.map((item, i) => { + calls.push(i) + return { item, doubled: item.value * 2 } + }) + expect(mapped.map((m) => m.item)).toEqual([...state.items]) + expect(mapped[0].item).toBe(state.items[0]) + expect(mapped[4].item).toBe(state.items[4]) + expect(mapped.map((m) => m.doubled)).toEqual([20, 40, 60, 80, 100]) + // The first element is scanned twice (recorder, then raw); the rest once + expect(calls).toEqual([0, 0, 1, 2, 3, 4]) + // Embedded elements force the coarse array dependency + expect(registry.has('items')).toBe(true) + }) + + test('nested object reads fall back to the coarse array signal', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + const counts = proxy.items.map((item) => item.nested.count) + expect(counts).toEqual([1, 2, 3, 4, 5]) + expect(registry.has('items')).toBe(true) + expect(registry.has('items.{*}.nested')).toBe(false) + }) + + test('callback receives correct index and array arguments', () => { + const state = createTestData() + const { proxy } = setup(state) + const seen: number[] = [] + proxy.items.map((_item, index, arr) => { + seen.push(index) + expect(arr).toBe(state.items) + return index + }) + expect(seen).toEqual([0, 1, 2, 3, 4]) + }) + }) + + describe('Non-overridden methods (pass-through)', () => { + test('forEach() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const values: number[] = [] + proxy.items.forEach((item) => { + values.push(item.value) + }) + expect(values).toEqual([10, 20, 30, 40, 50]) + }) + + test('reduce() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const sum = proxy.items.reduce((acc, item) => acc + item.value, 0) + expect(sum).toBe(150) + }) + + test('reduceRight() callbacks receive proxied values', () => { + const state = createTestData() + const { proxy } = setup(state) + const ids = proxy.items.reduceRight((acc: number[], item) => { + acc.push(item.id) + return acc + }, []) + expect(ids).toEqual([5, 4, 3, 2, 1]) + }) + }) + + describe('Signal tracking precision', () => { + test('find() registers the column read by the callback plus the result identity', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // Simulate what a selector does: find + read properties + const item = proxy.items.find((i) => i.id === 3)! + expect(item.value).toBe(30) + expect(item.nested.count).toBe(3) + + expect(registry.debugPaths().sort()).toEqual( + ['items.{*}.id', 'items.{id:3}', 'items.@@insertOrReorder'].sort(), + ) + }) + + test('filter() registers the column read by the callback plus matching identities', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + const highValue = proxy.items.filter((i) => i.value > 30) + expect(highValue.map((i) => i.value)).toEqual([40, 50]) + + expect(registry.debugPaths().sort()).toEqual( + [ + 'items.{*}.value', + 'items.{id:4}', + 'items.{id:5}', + 'items.@@append', + 'items.@@insertOrReorder', + 'items.@@remove', + ].sort(), + ) + }) + + test('primitive-returning methods register no element signals', () => { + const state = createTestData() + const { proxy, registry } = setup(state) + + // Call several primitive-returning methods + proxy.items.some((i) => i.id === 3) + proxy.items.every((i) => i.value > 0) + proxy.items.findIndex((i) => i.id === 4) + + // No element-level signals should exist + for (let id = 1; id <= 5; id++) { + expect(registry.has(`items.{id:${id}}.value`)).toBe(false) + expect(registry.has(`items.{id:${id}}.id`)).toBe(false) + } + }) + + test('selectById pattern: find + read is efficient', () => { + // This is the exact entity-list-array benchmark pattern + const items = Array.from({ length: 100 }, (_, i) => + deepFreeze({ + id: i, + value: i * 10, + label: `Item ${i}`, + category: 'A', + updatedAt: Date.now(), + }), + ) + const state = deepFreeze(items) + const registry = createPathSignalRegistry(alienEngine) + const cache: ProxyCache = new WeakMap() + const proxy = createTrackingProxy(state, 'items', registry, cache) + + // selectById pattern + const found = proxy.find((i) => i.id === 42)! + expect(found.value).toBe(420) + expect(found.label).toBe('Item 42') + + // One column signal for the scan, one identity signal for the match, + // and nothing for the other 99 entities + expect(registry.has('items.{*}.id')).toBe(true) + expect(registry.has('items.{id:42}')).toBe(true) + expect(registry.has('items.{id:42}.value')).toBe(false) + expect(registry.has('items.{id:41}')).toBe(false) + expect(registry.has('items.{id:43}')).toBe(false) + expect(registry.size()).toBe(3) + }) + }) + + describe('Edge cases', () => { + test('empty array', () => { + const state = deepFreeze({ items: [] as { id: number }[] }) + const { proxy } = setup(state) + expect(proxy.items.find(() => true)).toBeUndefined() + expect(proxy.items.filter(() => true)).toEqual([]) + expect(proxy.items.some(() => true)).toBe(false) + expect(proxy.items.every(() => true)).toBe(true) + expect(proxy.items.indexOf({ id: 1 })).toBe(-1) + expect(proxy.items.join(',')).toBe('') + }) + + test('single-element array', () => { + const state = deepFreeze({ items: [{ id: 1, value: 10 }] }) + const { proxy } = setup(state) + const found = proxy.items.find((i) => i.id === 1) + expect(found).toBeDefined() + expect(found!.value).toBe(10) + }) + + test('array of primitives (no identity detection)', () => { + const state = deepFreeze({ nums: [5, 10, 15, 20, 25] }) + const { proxy } = setup(state) + expect(proxy.nums.find((n) => n > 12)).toBe(15) + expect(proxy.nums.filter((n) => n > 12)).toEqual([15, 20, 25]) + expect(proxy.nums.some((n) => n > 20)).toBe(true) + expect(proxy.nums.indexOf(15)).toBe(2) + }) + + test('nested array access', () => { + const state = deepFreeze({ + groups: [ + { + id: 1, + items: [ + { id: 10, name: 'a' }, + { id: 11, name: 'b' }, + ], + }, + { id: 2, items: [{ id: 20, name: 'c' }] }, + ], + }) + const { proxy } = setup(state) + const group = proxy.groups.find((g) => g.id === 1)! + const item = group.items.find((i) => i.id === 11)! + expect(item.name).toBe('b') + }) + + test('chained array methods', () => { + const state = createTestData() + const { proxy } = setup(state) + // filter then find — both should work with overrides + const highValue = proxy.items.filter((i) => i.value > 20) + // highValue is a plain array of proxied items (not a tracking proxy itself) + // so .find on it will use normal Array.prototype.find + const found = highValue.find((i) => i.id === 4) + expect(found).toBeDefined() + expect(found!.value).toBe(40) + }) + + test('array.length is not affected by overrides', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(proxy.items.length).toBe(5) + }) + + test('for...of iteration still works (not overridden)', () => { + const state = createTestData() + const { proxy } = setup(state) + const ids: number[] = [] + for (const item of proxy.items) { + ids.push(item.id) + } + expect(ids).toEqual([1, 2, 3, 4, 5]) + }) + + test('spread still works', () => { + const state = createTestData() + const { proxy } = setup(state) + const copy = [...proxy.items] + expect(copy).toHaveLength(5) + expect(copy[0].id).toBe(1) + }) + + test('Array.isArray still works', () => { + const state = createTestData() + const { proxy } = setup(state) + expect(Array.isArray(proxy.items)).toBe(true) + }) + + test('array destructuring works', () => { + const state = createTestData() + const { proxy } = setup(state) + const [first, second] = proxy.items + expect(first.id).toBe(1) + expect(second.id).toBe(2) + }) + }) +}) diff --git a/test/signals/coarseSegments.spec.ts b/test/signals/coarseSegments.spec.ts new file mode 100644 index 000000000..b1dd203cd --- /dev/null +++ b/test/signals/coarseSegments.spec.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest' +import type { CoarseSub } from '../../src/signals/coarseSegments' +import { + createProbeProxy, + createSegmentIndex, +} from '../../src/signals/coarseSegments' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { reconcileState } from '../../src/signals/diff' +import { unwrap } from '../../src/signals/trackingProxy' + +function makeSub(segments: Set | null): CoarseSub & { hits: number } { + const sub: CoarseSub & { hits: number } = { + segments, + hits: 0, + onCoarseHit() { + sub.hits++ + }, + } + return sub +} + +describe('SegmentIndex', () => { + it('collects subs whose segments intersect the changed root keys', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const subB = makeSub(new Set(['b'])) + const subAB = makeSub(new Set(['a', 'b'])) + index.register(subA) + index.register(subB) + index.register(subAB) + + expect(index.collect(['a'])).toEqual(expect.arrayContaining([subA, subAB])) + expect(index.collect(['a'])).toHaveLength(2) + expect(index.collect(['c'])).toHaveLength(0) + }) + + it('dedupes a sub matched by multiple changed keys', () => { + const index = createSegmentIndex() + const subAB = makeSub(new Set(['a', 'b'])) + index.register(subAB) + + expect(index.collect(['a', 'b'])).toHaveLength(1) + }) + + it('collect(null) returns every registered sub (non-diffable root)', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const wildcard = makeSub(null) + index.register(subA) + index.register(wildcard) + + expect(index.collect(null)).toHaveLength(2) + }) + + it('collect([]) returns only wildcard subs', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + const wildcard = makeSub(null) + index.register(subA) + index.register(wildcard) + + const hits = index.collect([]) + expect(hits).toHaveLength(1) + expect(hits[0]).toBe(wildcard) + }) + + it('wildcard subs are collected for any changed key', () => { + const index = createSegmentIndex() + const wildcard = makeSub(null) + index.register(wildcard) + + expect(index.collect(['whatever'])).toHaveLength(1) + }) + + it('unregister removes a sub and is idempotent', () => { + const index = createSegmentIndex() + const subA = makeSub(new Set(['a'])) + index.register(subA) + expect(index.size()).toBe(1) + + index.unregister(subA) + expect(index.size()).toBe(0) + expect(index.collect(['a'])).toHaveLength(0) + + // Second unregister is a no-op, not an error. + index.unregister(subA) + expect(index.size()).toBe(0) + }) + + it('a sub can unregister itself during onCoarseHit without breaking iteration', () => { + const index = createSegmentIndex() + const order: string[] = [] + const subs: CoarseSub[] = [] + for (const name of ['one', 'two', 'three']) { + const sub: CoarseSub = { + segments: new Set(['a']), + onCoarseHit() { + order.push(name) + index.unregister(sub) + }, + } + subs.push(sub) + index.register(sub) + } + + const hits = index.collect(['a']) + for (const hit of hits) { + hit.onCoarseHit() + } + + expect(order).toEqual(['one', 'two', 'three']) + expect(index.size()).toBe(0) + }) + + it('re-registering a sub with different segments replaces the old registration', () => { + const index = createSegmentIndex() + const sub = makeSub(new Set(['a'])) + index.register(sub) + index.unregister(sub) + + const swapped = makeSub(new Set(['b'])) + index.register(swapped) + + expect(index.collect(['a'])).toHaveLength(0) + expect(index.collect(['b'])).toHaveLength(1) + expect(index.size()).toBe(1) + }) +}) + +describe('createProbeProxy', () => { + const state = Object.freeze({ + a: Object.freeze({ value: 1 }), + b: Object.freeze({ value: 2 }), + list: Object.freeze([1, 2, 3]), + }) + + it('records top-level keys read through the proxy and reads through to raw values', () => { + const { proxy, record } = createProbeProxy(state) + + const a = proxy.a + // In dev, nested objects come back wrapped in a write guard that + // reads through to the raw object and unwraps to it. + expect(unwrap(a)).toBe(state.a) + expect(a.value).toBe(1) + + expect(record.segments).toEqual(new Set(['a'])) + expect(record.enumerated).toBe(false) + }) + + it('records only the keys actually read', () => { + const { proxy, record } = createProbeProxy(state) + + void proxy.a + void proxy.list + + expect(record.segments).toEqual(new Set(['a', 'list'])) + }) + + it("records segments for 'in' checks", () => { + const { proxy, record } = createProbeProxy(state) + + expect('a' in proxy).toBe(true) + expect('missing' in proxy).toBe(false) + + expect(record.segments).toEqual(new Set(['a', 'missing'])) + }) + + it('records segments for hasOwnProperty checks', () => { + const { proxy, record } = createProbeProxy(state) + + expect(Object.prototype.hasOwnProperty.call(proxy, 'b')).toBe(true) + + expect(record.segments.has('b')).toBe(true) + }) + + it('flags enumeration (Object.keys)', () => { + const { proxy, record } = createProbeProxy(state) + + expect(Object.keys(proxy)).toEqual(['a', 'b', 'list']) + expect(record.enumerated).toBe(true) + }) + + it('flags enumeration (spread)', () => { + const { proxy, record } = createProbeProxy(state) + + const copy = { ...proxy } + expect(unwrap(copy.a)).toBe(state.a) + expect(record.enumerated).toBe(true) + }) + + it('rejects writes at the top level and through nested dev write guards', () => { + const { proxy } = createProbeProxy(state) + const writable = proxy as any + + expect(() => { + writable.a = { value: 9 } + }).toThrow(/must not mutate state/) + expect(() => { + writable.a.value = 9 + }).toThrow(/assign to property 'value'/) + expect(() => proxy.list.slice().sort()).not.toThrow() + expect(() => (proxy.list as number[]).sort()).toThrow( + /must not mutate state/, + ) + // Frozen state was never touched. + expect(state.a.value).toBe(1) + expect(state.list).toEqual([1, 2, 3]) + }) + + it('does not record symbol reads', () => { + const { proxy, record } = createProbeProxy(state) + + void (proxy as any)[Symbol.toStringTag] + + expect(record.segments.size).toBe(0) + }) + + it('captures the partial footprint when the selector throws', () => { + const { proxy, record } = createProbeProxy(state) + + expect(() => { + void proxy.a + throw new Error('selector blew up') + }).toThrow('selector blew up') + + // Anything read before the throw is in the footprint — this is what + // makes gating on a partial footprint sound: the segment whose value + // controls the throwing path was read before the throw. + expect(record.segments).toEqual(new Set(['a'])) + }) + + it('unwrap() resolves the probe proxy back to the raw state', () => { + const { proxy } = createProbeProxy(state) + + expect(unwrap(proxy)).toBe(state) + }) + + it('rejects writes', () => { + const { proxy } = createProbeProxy(state) + const writable = proxy as any + + expect(() => { + writable.a = 42 + }).toThrow(TypeError) + expect(() => { + delete (proxy as any).a + }).toThrow(TypeError) + }) +}) + +describe('reconcileState changed-root-keys return value', () => { + function makeRegistry() { + return createPathSignalRegistry(alienEngine) + } + + it('returns [] when prev === next', () => { + const registry = makeRegistry() + const state = { a: 1 } + + expect(reconcileState(state, state, registry, alienEngine)).toEqual([]) + }) + + it('returns the top-level keys whose references changed', () => { + const registry = makeRegistry() + const prev = { a: { v: 1 }, b: { v: 2 }, c: { v: 3 } } + const next = { a: { v: 9 }, b: prev.b, c: prev.c } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['a']) + }) + + it('includes added keys', () => { + const registry = makeRegistry() + const prev: Record = { a: 1 } + const next = { a: 1, added: 2 } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['added']) + }) + + it('includes keys added with an undefined value', () => { + const registry = makeRegistry() + const prev: Record = { a: 1 } + const next = { a: 1, added: undefined } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual(['added']) + }) + + it('includes removed keys', () => { + const registry = makeRegistry() + const prev = { a: 1, removed: 2 } + const next = { a: 1 } + + expect(reconcileState(prev, next, registry, alienEngine)).toEqual([ + 'removed', + ]) + }) + + it('returns null when the root is not a plain object', () => { + const registry = makeRegistry() + const prev = new Map([['v', 1]]) + const next = new Map([['v', 2]]) + + expect(reconcileState(prev, next, registry, alienEngine)).toBeNull() + }) +}) diff --git a/test/signals/coarseTier.spec.tsx b/test/signals/coarseTier.spec.tsx new file mode 100644 index 000000000..15751b5e7 --- /dev/null +++ b/test/signals/coarseTier.spec.tsx @@ -0,0 +1,549 @@ +/** + * Integration tests for the coarse tier: hooks defer building their deep + * signal graph at mount. The initial render runs the selector once through + * a cheap top-level probe proxy, registers the touched root segments in + * the registry's SegmentIndex, and only builds the full tracking-proxy + * graph when a dispatch actually changes one of those segments. + * + * Key invariants under test: + * - No signals are materialized for a deferred hook (registry.size() === 0) + * - Irrelevant dispatches neither re-render nor promote a deferred hook + * - A relevant dispatch promotes exactly the affected hooks, which then + * behave identically to eagerly-built hooks + * - Swapping the selector while unbuilt re-probes and re-registers (the + * missed-update bug in react-redux PR #2355's version of this design) + * - Selectors the probe can't gate (root enumeration, empty footprint, + * non-plain-object root state) build eagerly at mount + */ +import type { UnknownAction } from '@reduxjs/toolkit' +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { useCallback, useState } from 'react' +import { legacy_createStore as createStore } from 'redux' +import type { PathSignalRegistry } from '../../src/signals' +import { + SignalProvider, + useSignalContext, + useSignalSelector, +} from '../../src/signals' + +const makeStore = () => { + const aSlice = createSlice({ + name: 'a', + initialState: { value: 0 }, + reducers: { + bumpA(state) { + state.value++ + }, + }, + }) + const bSlice = createSlice({ + name: 'b', + initialState: { value: 0 }, + reducers: { + bumpB(state) { + state.value++ + }, + }, + }) + + const store = configureStore({ + reducer: { a: aSlice.reducer, b: bSlice.reducer }, + }) + + return { + store, + bumpA: aSlice.actions.bumpA, + bumpB: bSlice.actions.bumpB, + } +} + +type AppState = ReturnType['store']['getState']> + +const noStabilityCheck = { + devModeChecks: { stabilityCheck: 'never' as const }, +} + +function makeRegistryCapture() { + const captured: { registry: PathSignalRegistry | null } = { registry: null } + function Capture() { + captured.registry = useSignalContext().registry + return null + } + return { captured, Capture } +} + +afterEach(() => { + rtl.cleanup() +}) + +describe('coarse tier: deferred deep-graph build', () => { + it('defers building the deep graph until a dispatch touches a probed segment', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let selectorCalls = 0 + let renders = 0 + + const selectAValue = (s: AppState) => { + selectorCalls++ + return s.a.value + } + + function Watcher() { + renders++ + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('value').textContent).toBe('0') + expect(selectorCalls).toBe(1) // one probe run, nothing else + expect(renders).toBe(1) + expect(registry.size()).toBe(0) // no signals materialized + expect(registry.segmentIndex.size()).toBe(1) + + // Irrelevant dispatch: doesn't re-render, doesn't run the selector, + // doesn't promote. + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(renders).toBe(1) + expect(selectorCalls).toBe(1) + expect(registry.size()).toBe(0) + expect(registry.segmentIndex.size()).toBe(1) + + // Relevant dispatch: promotes, builds the deep graph, re-renders. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBeGreaterThan(0) + + // Post-promotion precision: the deep graph ignores b-only dispatches. + const rendersAfterPromotion = renders + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(renders).toBe(rendersAfterPromotion) + + // And keeps tracking a. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('2') + }) + + it('re-probes when the selector is swapped while unbuilt (missed-update bug in PR #2355)', () => { + const { store, bumpA, bumpB } = makeStore() + let renders = 0 + + function Watcher() { + renders++ + const [useB, setUseB] = useState(false) + const value = useSignalSelector( + useCallback((s: AppState) => (useB ? s.b.value : s.a.value), [useB]), + noStabilityCheck, + ) + return ( +
+
{value}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('0') + + // Swap the selector's footprint from {a} to {b} while still unbuilt. + rtl.fireEvent.click(getByTestId('toggle')) + expect(getByTestId('value').textContent).toBe('0') // b.value is still 0 + + // A dispatch that changes b MUST reach this hook. A stale {a} + // registration (the #2355 bug) would miss this update forever. + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(getByTestId('value').textContent).toBe('1') + + // After promotion the deep graph tracks only b. + const rendersAfter = renders + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(renders).toBe(rendersAfter) + }) + + it("gates on segments probed via 'in' checks and promotes when the key appears", () => { + interface RootState { + a: { v: number } + dynamicKey?: number + } + const reducer = ( + state: RootState = { a: { v: 0 } }, + action: UnknownAction, + ): RootState => { + switch (action.type) { + case 'addKey': + return { ...state, dynamicKey: 1 } + case 'bumpA': + return { ...state, a: { v: state.a.v + 1 } } + default: + return state + } + } + const store = configureStore({ reducer }) + let renders = 0 + + const selectHasKey = (s: RootState) => 'dynamicKey' in s + + function Watcher() { + renders++ + const has = useSignalSelector(selectHasKey, noStabilityCheck) + return
{String(has)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('has').textContent).toBe('false') + + // Changing an unrelated segment doesn't wake the hook. + rtl.act(() => { + store.dispatch({ type: 'bumpA' }) + }) + expect(renders).toBe(1) + + // Adding the probed key does. + rtl.act(() => { + store.dispatch({ type: 'addKey' }) + }) + expect(getByTestId('has').textContent).toBe('true') + }) + + it('builds eagerly when the selector enumerates the root (Object.keys)', () => { + interface RootState { + a: { v: number } + dynamicKey?: number + } + const reducer = ( + state: RootState = { a: { v: 0 } }, + action: UnknownAction, + ): RootState => { + switch (action.type) { + case 'addKey': + return { ...state, dynamicKey: 1 } + default: + return state + } + } + const store = configureStore({ reducer }) + const { captured, Capture } = makeRegistryCapture() + + const selectKeyCount = (s: RootState) => Object.keys(s).length + + function Watcher() { + const count = useSignalSelector(selectKeyCount, noStabilityCheck) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('count').textContent).toBe('1') + // Enumeration can't be gated by top-level segments — built at mount. + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBeGreaterThan(0) + + rtl.act(() => { + store.dispatch({ type: 'addKey' }) + }) + expect(getByTestId('count').textContent).toBe('2') + }) + + it('skips coarse registration for an identity selector and returns the raw state', () => { + const { store } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let selected: AppState | null = null + + const identity = (s: AppState) => s + + function Watcher() { + selected = useSignalSelector(identity, { + devModeChecks: { + stabilityCheck: 'never', + identityFunctionCheck: 'never', + }, + }) + return null + } + + rtl.render( + + + + , + ) + + const registry = captured.registry! + // Empty probe footprint (returned the root without reading keys): + // can't be gated, so the deep graph builds at mount. + expect(registry.segmentIndex.size()).toBe(0) + // The hook hands back the RAW state object, not a proxy. + expect(selected).toBe(store.getState()) + + // NOTE: `s => s` does NOT re-render on dispatch — the reconcile pass + // never bumps a signal for the root path, so an identity selector has + // no dependencies. This is a PRE-EXISTING limitation of the deep + // tracking system (verified against the pre-coarse-tier + // implementation), not a coarse-tier behavior, so it is not asserted + // here. Dev mode already warns about identity selectors. + }) + + it('serves a fresh snapshot when a dispatch lands between render and subscribe', () => { + const { store, bumpA } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + // Layout effects run before useSyncExternalStore's passive subscribe + // effect, so this dispatch lands in the render->subscribe gap. + function LayoutDispatcher() { + React.useLayoutEffect(() => { + store.dispatch(bumpA()) + }, []) + return null + } + + const { getByTestId } = rtl.render( + + + + + , + ) + + // The probe saw a.value === 0; the snapshot must reflect the dispatch. + expect(getByTestId('value').textContent).toBe('1') + + // And the hook keeps working afterward. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('2') + }) + + it('registers exactly one coarse sub under StrictMode double-subscribe', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + + , + ) + + const registry = captured.registry! + expect(getByTestId('value').textContent).toBe('0') + // subscribe -> cleanup -> resubscribe must not leak a registration. + expect(registry.segmentIndex.size()).toBe(1) + expect(registry.size()).toBe(0) + + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(registry.segmentIndex.size()).toBe(1) + + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + }) + + it('cleans up the coarse registration when unmounted while unbuilt', () => { + const { store, bumpA } = makeStore() + const { captured, Capture } = makeRegistryCapture() + + const selectAValue = (s: AppState) => s.a.value + + function Watcher() { + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + + function App() { + const [show, setShow] = useState(true) + return ( + + {show ? : null} + + + + ) + } + + const { getByTestId } = rtl.render() + const registry = captured.registry! + expect(registry.segmentIndex.size()).toBe(1) + + rtl.fireEvent.click(getByTestId('hide')) + expect(registry.segmentIndex.size()).toBe(0) + expect(registry.size()).toBe(0) + + // A now-relevant dispatch finds no subscriber and breaks nothing. + expect(() => { + rtl.act(() => { + store.dispatch(bumpA()) + }) + }).not.toThrow() + expect(registry.size()).toBe(0) + }) + + it('promotes hooks independently: only the hook whose segment changed wakes up', () => { + const { store, bumpA, bumpB } = makeStore() + const { captured, Capture } = makeRegistryCapture() + let rendersA = 0 + let rendersB = 0 + + const selectAValue = (s: AppState) => s.a.value + const selectBValue = (s: AppState) => s.b.value + + function WatcherA() { + rendersA++ + const value = useSignalSelector(selectAValue, noStabilityCheck) + return
{value}
+ } + function WatcherB() { + rendersB++ + const value = useSignalSelector(selectBValue, noStabilityCheck) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + const registry = captured.registry! + expect(registry.segmentIndex.size()).toBe(2) + expect(registry.size()).toBe(0) + + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('a').textContent).toBe('1') + expect(rendersB).toBe(1) // B never re-rendered + expect(registry.segmentIndex.size()).toBe(1) // B is still deferred + expect(registry.size()).toBeGreaterThan(0) // A's graph exists + + rtl.act(() => { + store.dispatch(bumpB()) + }) + expect(getByTestId('b').textContent).toBe('1') + expect(registry.segmentIndex.size()).toBe(0) + }) + + it('propagates a selector throw during the mount probe', () => { + const { store } = makeStore() + + const throwing = (s: AppState) => + (s.a as unknown as { missing: { value: number } }).missing.value + + function Watcher() { + const value = useSignalSelector(throwing, noStabilityCheck) + return
{value}
+ } + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => { + rtl.render( + + + , + ) + }).toThrow() + } finally { + spy.mockRestore() + } + }) + + it('declines to probe a non-plain root (Map roots are unsupported, pre-existing)', () => { + // The deep tracking system does not support Map root state: the + // tracking proxy's shell breaks Map method receivers, so mount throws + // "Method Map.prototype.get called on incompatible receiver". That + // failure predates the coarse tier (verified against the + // pre-coarse-tier implementation). What the coarse tier guarantees is + // that a non-plain root is never PROBED — the hook goes straight to + // the eager-build path and fails the same way it always did, instead + // of failing differently inside the probe proxy. + const store = createStore( + (state: Map = new Map([['v', 0]]), action) => + action.type === 'bump' + ? new Map([['v', (state.get('v') ?? 0) + 1]]) + : state, + ) + + const selectV = (s: Map) => s.get('v') + + function Watcher() { + const value = useSignalSelector(selectV, noStabilityCheck) + return
{value}
+ } + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => { + rtl.render( + + + , + ) + }).toThrow(/incompatible receiver/) + } finally { + spy.mockRestore() + } + }) +}) diff --git a/test/signals/createSignalSelectorHook.spec.tsx b/test/signals/createSignalSelectorHook.spec.tsx new file mode 100644 index 000000000..661334365 --- /dev/null +++ b/test/signals/createSignalSelectorHook.spec.tsx @@ -0,0 +1,242 @@ +/** + * Tests for createSignalSelectorHook — custom-context parity with stock + * createSelectorHook. SignalProvider already accepts a `context` prop; + * these tests verify the hook side binds to that context correctly. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import { createContext } from 'react' +import { describe, expect, it, afterEach } from 'vitest' +import type { ReactReduxContextValue } from '../../src/components/Context' +// Import the stock Provider directly from source — the package root +// currently carries the bench override (Provider = SignalProvider). +import Provider from '../../src/components/Provider' +import { + createSignalSelectorHook, + SignalProvider, + useSignalSelector, +} from '../../src/signals' + +const makeStore = (initialValue = 0) => { + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: initialValue }, + reducers: { + increment: (state) => { + state.value += 1 + }, + incrementBy: (state, action: { payload: number }) => { + state.value += action.payload + }, + }, + }) + + const store = configureStore({ + reducer: { counter: counterSlice.reducer }, + }) + + return { + store, + increment: counterSlice.actions.increment, + incrementBy: counterSlice.actions.incrementBy, + } +} + +type RootState = { counter: { value: number } } + +const customContext = createContext | null>( + null, +) + +const useCustomSelector = createSignalSelectorHook(customContext) + +afterEach(() => { + rtl.cleanup() +}) + +describe('createSignalSelectorHook', () => { + it('reads from the store provided through the custom context', () => { + const { store } = makeStore(7) + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('7') + }) + + it('bound and default hooks read their own stores when providers nest', () => { + const defaultStore = makeStore(1) + const customStore = makeStore(100) + + function Display() { + const defaultValue = useSignalSelector((s: RootState) => s.counter.value) + const customValue = useCustomSelector((s: RootState) => s.counter.value) + return ( +
+
{defaultValue}
+
{customValue}
+
+ ) + } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('default').textContent).toBe('1') + expect(getByTestId('custom').textContent).toBe('100') + + rtl.act(() => { + customStore.store.dispatch(customStore.increment()) + }) + expect(getByTestId('default').textContent).toBe('1') + expect(getByTestId('custom').textContent).toBe('101') + + rtl.act(() => { + defaultStore.store.dispatch(defaultStore.increment()) + }) + expect(getByTestId('default').textContent).toBe('2') + expect(getByTestId('custom').textContent).toBe('101') + }) + + it('dispatches to the other store do not re-render a bound-hook component', () => { + const defaultStore = makeStore(1) + const customStore = makeStore(100) + let renders = 0 + + function CustomOnly() { + renders++ + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + rtl.render( + + + + + , + ) + + expect(renders).toBe(1) + + rtl.act(() => { + defaultStore.store.dispatch(defaultStore.increment()) + }) + expect(renders).toBe(1) + + rtl.act(() => { + customStore.store.dispatch(customStore.increment()) + }) + expect(renders).toBe(2) + }) + + it('throws when the bound context has no provider', () => { + const { store } = makeStore() + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + // Default SignalProvider present, but the custom context is empty + expect(() => + rtl.render( + + + , + ), + ).toThrow(/must be used within a /) + }) + + it('throws when the custom context was filled by a regular ', () => { + const { store } = makeStore() + + function Display() { + const value = useCustomSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + expect(() => + rtl.render( + + + , + ), + ).toThrow(/not a regular /) + }) + + it('factory with no arguments creates an independent hook bound to the default context', () => { + const freshHook = createSignalSelectorHook() + expect(freshHook).not.toBe(useSignalSelector) + + const { store } = makeStore(3) + + function Display() { + const value = freshHook((s: RootState) => s.counter.value) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('3') + }) + + it('withTypes on a bound hook returns the same hook', () => { + const typed = useCustomSelector.withTypes() + expect(typed).toBe(useCustomSelector) + }) + + it('supports the options-object second argument on bound hooks', () => { + const { store, increment, incrementBy } = makeStore(0) + let renders = 0 + + function Display() { + renders++ + const parity = useCustomSelector( + (s: RootState) => [s.counter.value % 2] as const, + { equalityFn: (a, b) => a[0] === b[0] }, + ) + return
{String(parity[0])}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('parity').textContent).toBe('0') + expect(renders).toBe(1) + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getByTestId('parity').textContent).toBe('1') + expect(renders).toBe(2) + + rtl.act(() => { + store.dispatch(incrementBy(2)) + }) + // 1 -> 3 in a single dispatch: parity unchanged, equalityFn + // suppresses the re-render + expect(getByTestId('parity').textContent).toBe('1') + expect(renders).toBe(2) + }) +}) diff --git a/test/signals/depChurn.spec.ts b/test/signals/depChurn.spec.ts new file mode 100644 index 000000000..1c3b465d5 --- /dev/null +++ b/test/signals/depChurn.spec.ts @@ -0,0 +1,234 @@ +import { describe, it, expect } from 'vitest' +import { createSelector } from 'reselect' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createLeafTracker, + createTrackingProxy, + finalizeDependencies, + getProxyPath, +} from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' + +// Regression guard for dependency churn with memoized collection selectors. +// +// Several hooks share `selectAllComments` (RTK entity-adapter `selectAll` +// shape: `ids.map(id => entities[id])`). Whichever hook happens to run the +// map on a Reselect cache miss is the only one that reads the elements. If +// that read registered one signal per element, the next time that hook +// re-ran for an unrelated reason it would get a cache hit, read no elements, +// and alien-signals would drop every element dependency — releasing all of +// them from the registry — only for the next comments change to recreate +// them all. With 1000 comments and a dispatch every 25 ms that teardown and +// rebuild dominated the derived-selectors benchmark. +// +// A mapped collection is a read of the whole container, so the dependency +// belongs on the container (`comments.entities`), which every hook holds +// stably. + +interface Comment { + id: number + postId: number + text: string +} +interface Post { + id: number + score: number +} +interface State { + posts: { ids: number[]; entities: Record } + comments: { ids: number[]; entities: Record } +} + +const NUM_POSTS = 5 +const NUM_COMMENTS = 50 +const NUM_HOOKS = 6 + +function makeState(): State { + const posts: State['posts'] = { ids: [], entities: {} } + const comments: State['comments'] = { ids: [], entities: {} } + for (let i = 1; i <= NUM_POSTS; i++) { + posts.ids.push(i) + posts.entities[i] = { id: i, score: 0 } + } + for (let i = 1; i <= NUM_COMMENTS; i++) { + comments.ids.push(i) + comments.entities[i] = { id: i, postId: (i % NUM_POSTS) + 1, text: 'c' + i } + } + return { posts, comments } +} + +function addComment(state: State, postId: number): State { + const id = state.comments.ids.length + 1 + return { + ...state, + comments: { + ids: [...state.comments.ids, id], + entities: { + ...state.comments.entities, + [id]: { id, postId, text: 'c' + id }, + }, + }, + } +} + +function updateScore(state: State, postId: number, score: number): State { + return { + ...state, + posts: { + ...state.posts, + entities: { + ...state.posts.entities, + [postId]: { ...state.posts.entities[postId], score }, + }, + }, + } +} + +function expectedCommentCount(state: State, postId: number): number { + return Object.values(state.comments.entities).filter( + (c) => c.postId === postId, + ).length +} + +const selectAllComments = createSelector( + [(s: State) => s.comments.ids, (s: State) => s.comments.entities], + (ids, entities) => ids.map((id) => entities[id]), +) +const selectPostById = (s: State, postId: number) => s.posts.entities[postId] + +const selectPostWithComments = createSelector( + [(s: State, postId: number) => selectPostById(s, postId), selectAllComments], + (post, comments) => ({ + ...post, + commentCount: comments.filter((c) => c.postId === post.id).length, + }), +) + +// Mirrors useSignalSelector's deep tier: a computed with leaf-object tracking +// plus an effect subscribed to it, so that dropping the last subscriber of a +// path signal releases it from the registry. +function mountHook( + getState: () => State, + selector: (s: State) => R, + registry: PathSignalRegistry, +) { + let evals = 0 + let last: R | undefined + const scope = alienEngine.createScope() + scope.run(() => { + const computed = alienEngine.computed(() => { + evals++ + const leafTracker = createLeafTracker() + const proxy = createTrackingProxy( + getState(), + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as State) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) registry.getOrCreate(proxyPath, result).get() + finalizeDependencies(leafTracker, registry) + return result + }) + alienEngine.effect(() => { + last = computed.get() + }) + }) + return { + getEvals: () => evals, + getLast: () => last, + stop: () => scope.stop(), + } +} + +// Records which path signals the registry creates and releases. +function instrument(registry: PathSignalRegistry) { + let created: string[] = [] + let released: string[] = [] + const origGetOrCreate = registry.getOrCreate + const origRelease = registry.release + registry.getOrCreate = (path, value) => { + if (!registry.has(path)) created.push(path) + return origGetOrCreate(path, value) + } + registry.release = (path, node) => { + released.push(path) + return origRelease(path, node) + } + return { + drain() { + const snapshot = { created, released } + created = [] + released = [] + return snapshot + }, + } +} + +const isElementPath = (p: string) => /^comments\.entities\.\d+/.test(p) + +describe('dependency churn with memoized collection selectors', () => { + it('keeps element signals stable when a shared selectAll is memoized', () => { + const registry = createPathSignalRegistry(alienEngine) + const recorder = instrument(registry) + let state = makeState() + const getState = () => state + + const hookPostIds = Array.from( + { length: NUM_HOOKS }, + (_, i) => (i % NUM_POSTS) + 1, + ) + const hooks = hookPostIds.map((postId) => + mountHook( + getState, + (s) => selectPostWithComments(s, postId).commentCount, + registry, + ), + ) + + const mount = recorder.drain() + expect(mount.created.filter(isElementPath)).toEqual([]) + expect(registry.has('comments.entities')).toBe(true) + expect(registry.has('comments.ids')).toBe(true) + + const dispatch = (next: State) => { + const prev = state + state = next + const before = hooks.map((h) => h.getEvals()) + reconcileState(prev, next, registry, alienEngine) + const evalDeltas = hooks.map((h, i) => h.getEvals() - before[i]) + const { created, released } = recorder.drain() + hooks.forEach((h, i) => { + expect(h.getLast()).toBe(expectedCommentCount(state, hookPostIds[i])) + }) + return { evalDeltas, created, released } + } + + const commentsChanged = () => hooks.map(() => 1) + const onlyPost = (postId: number) => + hookPostIds.map((id) => (id === postId ? 1 : 0)) + + const steps = [ + { next: () => addComment(state, 1), evals: commentsChanged() }, + { next: () => addComment(state, 2), evals: commentsChanged() }, + { next: () => updateScore(state, 3, 10), evals: onlyPost(3) }, + { next: () => addComment(state, 3), evals: commentsChanged() }, + { next: () => updateScore(state, 1, 20), evals: onlyPost(1) }, + { next: () => addComment(state, 4), evals: commentsChanged() }, + { next: () => addComment(state, 5), evals: commentsChanged() }, + ] + + for (const step of steps) { + const { evalDeltas, created, released } = dispatch(step.next()) + expect(evalDeltas).toEqual(step.evals) + expect(created.filter(isElementPath)).toEqual([]) + expect(released.filter(isElementPath)).toEqual([]) + } + + hooks.forEach((h) => h.stop()) + }) +}) diff --git a/test/signals/devModeChecks.spec.tsx b/test/signals/devModeChecks.spec.tsx new file mode 100644 index 000000000..942194253 --- /dev/null +++ b/test/signals/devModeChecks.spec.tsx @@ -0,0 +1,441 @@ +/** + * Tests for useSignalSelector's stock-useSelector API parity: + * + * - second argument as equalityFn OR options object ({ equalityFn, + * devModeChecks }) + * - dev-mode argument validation + * - identityFunctionCheck (state => state) — for the signal hook this is + * a CORRECTNESS warning: the root path is never updated by the diff, + * so an identity selector never re-renders at all + * - stabilityCheck (selector returns a new reference for the same state) + * - Provider-level defaults + per-hook overrides + * - useSignalSelector.withTypes() + * + * "once" frequency is literally once per hook instance, on the first + * evaluation — matching stock useSelector. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React from 'react' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +const makeStore = () => { + const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + increment: (state) => { + state.value += 1 + }, + }, + }) + + const todosSlice = createSlice({ + name: 'todos', + initialState: { + list: [ + { id: 1, text: 'buy milk', done: false }, + { id: 2, text: 'walk dog', done: true }, + ], + }, + reducers: { + addTodo: (state) => { + state.list.push({ + id: state.list.length + 1, + text: 'new todo', + done: false, + }) + }, + }, + }) + + const store = configureStore({ + reducer: { + counter: counterSlice.reducer, + todos: todosSlice.reducer, + }, + }) + + return { + store, + increment: counterSlice.actions.increment, + addTodo: todosSlice.actions.addTodo, + } +} + +type AppStore = ReturnType['store'] +type RootState = ReturnType + +const spyOnConsoleWarn = () => + vi.spyOn(console, 'warn').mockImplementation(() => {}) + +let warnSpy: ReturnType + +beforeEach(() => { + warnSpy = spyOnConsoleWarn() +}) + +afterEach(() => { + warnSpy.mockRestore() + rtl.cleanup() +}) + +const identityWarnings = () => + warnSpy.mock.calls.filter((call) => + String(call[0]).includes('returned the root state'), + ) + +const stabilityWarnings = () => + warnSpy.mock.calls.filter((call) => + String(call[0]).includes('returned a different result'), + ) + +describe('equalityFn argument forms', () => { + const firstElementEqual = (a: boolean[], b: boolean[]) => a[0] === b[0] + + const renderCounter = ( + store: AppStore, + useSelectorArgs: (state: RootState) => boolean[], + second?: any, + ) => { + let renders = 0 + function Comp() { + renders++ + const flags = useSignalSelector(useSelectorArgs, second) + return
{String(flags[0])}
+ } + const utils = rtl.render( + + + , + ) + return { utils, getRenders: () => renders } + } + + it('accepts a positional equality function', () => { + const { store, increment } = makeStore() + const { utils, getRenders } = renderCounter( + store, + (s) => [s.counter.value > 0], + firstElementEqual, + ) + + expect(utils.getByTestId('flag').textContent).toBe('false') + const initialRenders = getRenders() + + // 0 -> 1 crosses the threshold: new result, re-render + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('flag').textContent).toBe('true') + expect(getRenders()).toBe(initialRenders + 1) + + // 1 -> 2: selector re-runs (fresh array) but equalityFn says equal + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getRenders()).toBe(initialRenders + 1) + }) + + it('accepts an options object with equalityFn', () => { + const { store, increment } = makeStore() + const { utils, getRenders } = renderCounter( + store, + (s) => [s.counter.value > 0], + { equalityFn: firstElementEqual }, + ) + + expect(utils.getByTestId('flag').textContent).toBe('false') + const initialRenders = getRenders() + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('flag').textContent).toBe('true') + expect(getRenders()).toBe(initialRenders + 1) + + rtl.act(() => { + store.dispatch(increment()) + }) + expect(getRenders()).toBe(initialRenders + 1) + }) + + it('defaults to Object.is without a second argument', () => { + const { store, increment } = makeStore() + let renders = 0 + function Comp() { + renders++ + const value = useSignalSelector((s: RootState) => s.counter.value) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + expect(utils.getByTestId('value').textContent).toBe('0') + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('value').textContent).toBe('1') + expect(renders).toBe(2) + }) +}) + +describe('dev-mode argument validation', () => { + const renderWith = (hookCall: () => unknown) => { + const { store } = makeStore() + function Comp() { + hookCall() + return null + } + rtl.render( + + + , + ) + } + + it('throws if no selector is passed', () => { + expect(() => renderWith(() => (useSignalSelector as any)())).toThrow( + /You must pass a selector to useSignalSelector/, + ) + }) + + it('throws if the selector is not a function', () => { + expect(() => + renderWith(() => (useSignalSelector as any)('counter')), + ).toThrow(/You must pass a function as a selector/) + }) + + it('throws if the equality function is not a function', () => { + expect(() => + renderWith(() => + (useSignalSelector as any)((s: RootState) => s.counter.value, { + equalityFn: 'nope', + }), + ), + ).toThrow(/You must pass a function as an equality function/) + }) +}) + +describe('identityFunctionCheck', () => { + it('warns once on mount for an identity selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(1) + expect(String(warnSpy.mock.calls[0][0])).toContain('NEVER re-render') + }) + + it('does not warn for a selector that reads specific paths', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.counter.value) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('is silenced by devModeChecks: { identityFunctionCheck: "never" }', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s, { + devModeChecks: { identityFunctionCheck: 'never' }, + }) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('respects the Provider-level default', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(0) + }) + + it('per-hook override beats the Provider-level default', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s, { + devModeChecks: { identityFunctionCheck: 'once' }, + }) + return null + } + rtl.render( + + + , + ) + expect(identityWarnings()).toHaveLength(1) + }) +}) + +describe('stabilityCheck', () => { + it('warns once on mount for an unstable selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + }) + + it('does not warn for a stable selector', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('uses the provided equalityFn for the comparison', () => { + const { store } = makeStore() + const shallowArrayEqual = (a: number[], b: number[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + equalityFn: shallowArrayEqual, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('is silenced by devModeChecks: { stabilityCheck: "never" }', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + devModeChecks: { stabilityCheck: 'never' }, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('is silenced by a Provider-level stabilityCheck="never"', () => { + const { store } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(0) + }) + + it('"always" re-checks on store-driven re-evaluations', () => { + const { store, addTodo } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id), { + devModeChecks: { stabilityCheck: 'always' }, + }) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + + rtl.act(() => { + store.dispatch(addTodo()) + }) + // One more warning per selector evaluation after the dispatch: + // one for the promotion run, one for the render-phase swap run + expect(stabilityWarnings()).toHaveLength(3) + }) + + it('"once" does not re-check on later evaluations', () => { + const { store, addTodo } = makeStore() + function Comp() { + useSignalSelector((s: RootState) => s.todos.list.map((t) => t.id)) + return null + } + rtl.render( + + + , + ) + expect(stabilityWarnings()).toHaveLength(1) + + rtl.act(() => { + store.dispatch(addTodo()) + }) + expect(stabilityWarnings()).toHaveLength(1) + }) +}) + +describe('withTypes', () => { + it('returns the same hook, pre-typed', () => { + const useAppSignalSelector = useSignalSelector.withTypes() + expect(useAppSignalSelector).toBe(useSignalSelector) + + const { store, increment } = makeStore() + function Comp() { + // No explicit state type needed — inferred from withTypes + const value = useAppSignalSelector((s) => s.counter.value) + return
{value}
+ } + const utils = rtl.render( + + + , + ) + expect(utils.getByTestId('value').textContent).toBe('0') + rtl.act(() => { + store.dispatch(increment()) + }) + expect(utils.getByTestId('value').textContent).toBe('1') + }) +}) diff --git a/test/signals/diff.spec.ts b/test/signals/diff.spec.ts new file mode 100644 index 000000000..3d9cd6242 --- /dev/null +++ b/test/signals/diff.spec.ts @@ -0,0 +1,724 @@ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { diffAndUpdateSignals, reconcileState } from '../../src/signals/diff' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' + +// Helper: create a registry with signals pre-populated by running a selector through a tracking proxy +function setupRegistry(state: object, selectorFn: (s: any) => void) { + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + selectorFn(proxy) + return registry +} + +// Helper: freeze deeply (Immer-style) +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object' && !Object.isFrozen(val)) { + deepFreeze(val) + } + } + return obj +} + +describe('diffAndUpdateSignals', () => { + describe('structural sharing short-circuits', () => { + it('skips entirely when prev === next', () => { + const state = deepFreeze({ + todos: [{ id: 1, text: 'hi' }], + filter: 'all', + }) + const registry = setupRegistry(state, (s) => { + s.todos[0].text + s.filter + }) + + // Get initial signal values (identity-based path for keyed array element) + const textSig = registry.getOrCreate('todos.{id:1}.text', 'hi') + const filterSig = registry.getOrCreate('filter', 'all') + const initialText = textSig.get() + const initialFilter = filterSig.get() + + // Diff with same reference — nothing should change + diffAndUpdateSignals(state, state, '', registry) + + expect(textSig.get()).toBe(initialText) + expect(filterSig.get()).toBe(initialFilter) + }) + + it('skips unchanged subtrees (Immer structural sharing)', () => { + const todos = deepFreeze([{ id: 1, text: 'hi', completed: false }]) + const prev = deepFreeze({ todos, filter: 'all', count: 0 }) + // Immer-style: share todos ref, only change count + const next = deepFreeze({ todos, filter: 'all', count: 1 }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.filter + s.count + }) + + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'hi') + const todosSig = registry.getOrCreate('todos', todos) + const countSig = registry.getOrCreate('count', 0) + const initialTodosVersion = todosSig.get() + const initialText = todoTextSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // todos subtree is shared — signals should NOT be updated + expect(todosSig.get()).toBe(initialTodosVersion) + expect(todoTextSig.get()).toBe(initialText) + // count should be updated + expect(countSig.get()).toBe(1) + }) + }) + + describe('primitive changes', () => { + it('updates primitive leaf signals', () => { + const prev = { name: 'Alice', age: 30 } + const next = { name: 'Bob', age: 30 } + + const registry = setupRegistry(prev, (s) => { + s.name + s.age + }) + + const nameSig = registry.getOrCreate('name', 'Alice') + const ageSig = registry.getOrCreate('age', 30) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(nameSig.get()).toBe('Bob') + expect(ageSig.get()).toBe(30) // unchanged + }) + + it('handles number changes', () => { + const prev = { value: 0 } + const next = { value: 42 } + const registry = setupRegistry(prev, (s) => s.value) + + const valueSig = registry.getOrCreate('value', 0) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(valueSig.get()).toBe(42) + }) + + it('handles boolean changes', () => { + const prev = { active: true } + const next = { active: false } + const registry = setupRegistry(prev, (s) => s.active) + + const activeSig = registry.getOrCreate('active', true) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(activeSig.get()).toBe(false) + }) + + it('handles null ↔ value changes', () => { + const prev = { data: null as string | null } + const next = { data: 'loaded' } + const registry = setupRegistry(prev, (s) => s.data) + + const dataSig = registry.getOrCreate('data', null) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(dataSig.get()).toBe('loaded') + }) + + it('handles undefined ↔ value changes', () => { + const prev = { data: undefined as string | undefined } + const next = { data: 'loaded' } + const registry = setupRegistry(prev, (s) => s.data) + + const dataSig = registry.getOrCreate('data', undefined) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(dataSig.get()).toBe('loaded') + }) + }) + + describe('object changes', () => { + it('bumps version counter for changed objects', () => { + const prev = { user: { name: 'Alice' } } + const next = { user: { name: 'Bob' } } + + const registry = setupRegistry(prev, (s) => s.user.name) + + const userSig = registry.getOrCreate('user', prev.user) + const nameSig = registry.getOrCreate('user.name', 'Alice') + const initialVersion = userSig.get() as number + + diffAndUpdateSignals(prev, next, '', registry) + + expect(userSig.get()).toBe(initialVersion + 1) + expect(nameSig.get()).toBe('Bob') + }) + + it('does not update untracked paths', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1, b: 99 } + // Only track 'a', not 'b' + const registry = setupRegistry(prev, (s) => s.a) + + const initialSize = registry.size() + diffAndUpdateSignals(prev, next, '', registry) + + // 'b' was never tracked, so no signal should be created for it + expect(registry.has('b')).toBe(false) + // Size should not have grown (update on untracked is a no-op) + expect(registry.size()).toBe(initialSize) + }) + + it('handles deeply nested changes', () => { + const prev = deepFreeze({ + a: { b: { c: { d: 'original' } } }, + }) + const next = deepFreeze({ + a: { b: { c: { d: 'changed' } } }, + }) + + const registry = setupRegistry(prev, (s) => s.a.b.c.d) + + const leafSig = registry.getOrCreate('a.b.c.d', 'original') + + diffAndUpdateSignals(prev, next, '', registry) + + // Leaf signal should be updated + expect(leafSig.get()).toBe('changed') + // Intermediate objects have no signals (only prefix registrations) + // They don't get version bumps — that's the optimization + expect(registry.has('a')).toBe(false) + expect(registry.has('a.b')).toBe(false) + expect(registry.has('a.b.c')).toBe(false) + // But prefix index knows about them + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.hasPrefix('a.b')).toBe(true) + expect(registry.hasPrefix('a.b.c')).toBe(true) + }) + }) + + describe('key changes (additions/removals)', () => { + it('updates @@keys signal when a key is added', () => { + const prev = { a: 1 } + const next = { a: 1, b: 2 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) // tracks @@keys + s.a + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // @@keys signal should have been updated + expect(keysSig.get()).not.toBe(initialKeysVersion) + }) + + it('updates @@keys signal when a key is removed', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) + s.a + s.b + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + expect(keysSig.get()).not.toBe(initialKeysVersion) + }) + + it('does not update @@keys when keys are unchanged', () => { + const prev = { a: 1, b: 2 } + const next = { a: 1, b: 99 } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s) + s.a + s.b + }) + + const keysSig = registry.getOrCreate('@@keys', Object.keys(prev)) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // Keys didn't change, only values + expect(keysSig.get()).toBe(initialKeysVersion) + }) + + it('handles nested key changes', () => { + const prev = { entities: { user1: { name: 'Alice' } } } + const next = { + entities: { user1: { name: 'Alice' }, user2: { name: 'Bob' } }, + } + + const registry = setupRegistry(prev, (s) => { + Object.keys(s.entities) + s.entities.user1.name + }) + + const entitiesKeysSig = registry.getOrCreate( + 'entities.@@keys', + Object.keys(prev.entities), + ) + const initialVersion = entitiesKeysSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + expect(entitiesKeysSig.get()).not.toBe(initialVersion) + }) + }) + + describe('array mutations', () => { + it('handles array item property change', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: false }, + { id: 2, text: 'second', completed: false }, + ], + }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: true }, // changed + { id: 2, text: 'second', completed: false }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].completed + s.todos[1].completed + }) + + // Identity-based paths: elements have 'id' field + const completedSig = registry.getOrCreate('todos.{id:1}.completed', false) + const otherCompletedSig = registry.getOrCreate( + 'todos.{id:2}.completed', + false, + ) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(completedSig.get()).toBe(true) + expect(otherCompletedSig.get()).toBe(false) // unchanged + }) + + it('handles array item addition (push)', () => { + const prev = deepFreeze({ todos: [{ id: 1, text: 'first' }] }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.todos.length + s.todos[0].text // registers at todos.{id:1}.text + }) + + // Track @@keys + const keysSignal = registry.getOrCreate('todos.@@keys', 1) + const initialKeysVersion = keysSignal.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // @@keys should update (length changed) + expect(keysSignal.get()).not.toBe(initialKeysVersion) + }) + + it('handles array item removal and prunes child signals', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + { id: 3, text: 'third' }, + ], + }) + const next = deepFreeze({ + todos: [ + { id: 1, text: 'first' }, + { id: 2, text: 'second' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.todos[1].text + s.todos[2].text + }) + + // Identity-based paths: elements have 'id' field + expect(registry.has('todos.{id:3}.text')).toBe(true) // leaf signal + expect(registry.hasPrefix('todos.{id:3}')).toBe(true) // prefix registered + + diffAndUpdateSignals(prev, next, '', registry) + + // Signals for removed entity should be pruned (by identity, not index) + expect(registry.has('todos.{id:3}.text')).toBe(false) + expect(registry.hasPrefix('todos.{id:3}')).toBe(false) + // Remaining entities should still exist + expect(registry.has('todos.{id:1}.text')).toBe(true) + expect(registry.has('todos.{id:2}.text')).toBe(true) + }) + + it('handles array length decrease with @@keys update', () => { + const prev = [1, 2, 3] + const next = [1] + + const registry = createPathSignalRegistry(alienEngine) + // Manually track some paths + const proxy = createTrackingProxy( + { items: prev }, + '', + registry, + registry.proxyCache, + ) + proxy.items.length // triggers @@keys and items tracking + + const keysSig = registry.getOrCreate('items.@@keys', prev.length) + const initialKeysVersion = keysSig.get() + + diffAndUpdateSignals({ items: prev }, { items: next }, '', registry) + + // @@keys updated for length change + expect(keysSig.get()).not.toBe(initialKeysVersion) + }) + + it('handles empty array to non-empty', () => { + const prev = { items: [] as number[] } + const next = { items: [1, 2, 3] } + + const registry = setupRegistry(prev, (s) => { + s.items.length + }) + + const keysSig = registry.getOrCreate('items.@@keys', 0) + const itemsSig = registry.getOrCreate('items', prev.items) + const initialKeysVersion = keysSig.get() + const initialItemsVersion = itemsSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // @@keys should be updated and items version-bumped + expect(keysSig.get()).not.toBe(initialKeysVersion) + expect(itemsSig.get()).not.toBe(initialItemsVersion) + }) + }) + + describe('type mismatches', () => { + it('handles object → primitive change', () => { + const prev = { data: { nested: 'value' } as unknown } + const next = { data: 'flat' as unknown } + + const registry = setupRegistry(prev, (s) => { + void (s.data as any).nested + }) + + expect(registry.has('data.nested')).toBe(true) + + diffAndUpdateSignals(prev, next, '', registry) + + // Child signals should be pruned when parent becomes primitive + expect(registry.has('data.nested')).toBe(false) + }) + + it('handles primitive → object change', () => { + const prev = { data: 'flat' as unknown } + const next = { data: { nested: 'value' } as unknown } + + const registry = setupRegistry(prev, (s) => s.data) + + const dataSig = registry.getOrCreate('data', 'flat') + expect(dataSig.get()).toBe('flat') + + diffAndUpdateSignals(prev, next, '', registry) + + // Signal transitions from primitive to version counter (resets to 0) + expect(dataSig.get()).toBe(0) + }) + + it('handles array → object change', () => { + const prev = { data: [1, 2, 3] as unknown } + const next = { data: { a: 1 } as unknown } + + const registry = setupRegistry(prev, (s) => { + void (s.data as any)[0] + void (s.data as any)[1] + }) + + // Primitive array elements use a coarse signal on the array itself + expect(registry.has('data')).toBe(true) + expect(registry.has('data.0')).toBe(false) + + const dataSig = registry.getOrCreate('data', prev.data) + const before = dataSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // The coarse array signal fires on the array → object transition + expect(dataSig.get()).not.toBe(before) + }) + }) + + describe('reactive propagation', () => { + it('triggers computed that depends on changed path signal', () => { + const prev = deepFreeze({ counter: 0, label: 'test' }) + const next = deepFreeze({ counter: 1, label: 'test' }) + + const registry = setupRegistry(prev, (s) => s.counter) + + let computedRuns = 0 + const counterSig = registry.getOrCreate('counter', 0) + const derived = alienEngine.computed(() => { + computedRuns++ + return counterSig.get() + }) + + // Initial read + expect(derived.get()).toBe(0) + const initialRuns = computedRuns + + diffAndUpdateSignals(prev, next, '', registry) + + expect(derived.get()).toBe(1) + expect(computedRuns).toBe(initialRuns + 1) + }) + + it('does NOT trigger computed that depends on unchanged path', () => { + const todos = deepFreeze([{ id: 1, text: 'hi' }]) + const prev = deepFreeze({ todos, counter: 0 }) + const next = Object.freeze({ todos, counter: 1 }) // share todos ref + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.counter + }) + + let todoComputedRuns = 0 + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'hi') + const todoComputed = alienEngine.computed(() => { + todoComputedRuns++ + return todoTextSig.get() + }) + + // Initial read + todoComputed.get() + const initialRuns = todoComputedRuns + + diffAndUpdateSignals(prev, next, '', registry) + + // Re-read — should NOT have re-run because todos was shared + todoComputed.get() + expect(todoComputedRuns).toBe(initialRuns) + }) + }) + + describe('NOOP dispatches', () => { + it('does nothing when state is identical reference', () => { + const state = deepFreeze({ a: 1, b: { c: 2 } }) + const registry = setupRegistry(state, (s) => { + s.a + s.b.c + }) + + const aSig = registry.getOrCreate('a', 1) + const cSig = registry.getOrCreate('b.c', 2) + const initialA = aSig.get() + const initialC = cSig.get() + + // Same reference — should be immediate no-op + diffAndUpdateSignals(state, state, '', registry) + + expect(aSig.get()).toBe(initialA) + expect(cSig.get()).toBe(initialC) + }) + }) + + describe('reconcileState (batched)', () => { + it('wraps diff in a batch for single propagation pass', () => { + const prev = { a: 1, b: 2, c: 3 } + const next = { a: 10, b: 20, c: 30 } + + const registry = setupRegistry(prev, (s) => { + s.a + s.b + s.c + }) + + let effectRuns = 0 + const aSig = registry.getOrCreate('a', 1) + const bSig = registry.getOrCreate('b', 2) + const cSig = registry.getOrCreate('c', 3) + + const derived = alienEngine.computed(() => { + return ( + (aSig.get() as number) + + (bSig.get() as number) + + (cSig.get() as number) + ) + }) + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + derived.get() + effectRuns++ + }) + }) + + const initialRuns = effectRuns + + // reconcileState should batch all updates + reconcileState(prev, next, registry, alienEngine) + + // Effect should fire exactly once (not 3 times) + expect(effectRuns).toBe(initialRuns + 1) + expect(derived.get()).toBe(60) + + scope.stop() + }) + + it('propagates all changes atomically', () => { + const prev = deepFreeze({ x: 1, y: 2 }) + const next = deepFreeze({ x: 10, y: 20 }) + + const registry = setupRegistry(prev, (s) => { + s.x + s.y + }) + + const xSig = registry.getOrCreate('x', 1) + const ySig = registry.getOrCreate('y', 2) + + const snapshots: { x: unknown; y: unknown }[] = [] + const derived = alienEngine.computed(() => ({ + x: xSig.get(), + y: ySig.get(), + })) + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + const val = derived.get() + snapshots.push(val) + }) + }) + + reconcileState(prev, next, registry, alienEngine) + + // Should only see final state, not intermediate {x:10, y:2} + const lastSnapshot = snapshots[snapshots.length - 1] + expect(lastSnapshot).toEqual({ x: 10, y: 20 }) + + scope.stop() + }) + }) + + describe('frozen state (Immer-style)', () => { + it('handles deeply frozen state correctly', () => { + const prev = deepFreeze({ + todos: [ + { id: 1, text: 'first', completed: false }, + { id: 2, text: 'second', completed: false }, + ], + counters: { + counter1: { value: 0, label: 'A' }, + counter2: { value: 0, label: 'B' }, + }, + filter: 'all', + }) + + // Immer-style update: share unchanged subtrees + const next = deepFreeze({ + todos: prev.todos, // shared ref + counters: { + counter1: { value: 1, label: 'A' }, // changed + counter2: prev.counters.counter2, // shared ref + }, + filter: 'all', + }) + + const registry = setupRegistry(prev, (s) => { + s.todos[0].text + s.counters.counter1.value + s.counters.counter2.value + s.filter + }) + + // Identity-based path for keyed array element + const todoTextSig = registry.getOrCreate('todos.{id:1}.text', 'first') + const c1ValueSig = registry.getOrCreate('counters.counter1.value', 0) + const c2ValueSig = registry.getOrCreate('counters.counter2.value', 0) + const filterSig = registry.getOrCreate('filter', 'all') + + const initialTodoText = todoTextSig.get() + const initialC2Value = c2ValueSig.get() + const initialFilter = filterSig.get() + + reconcileState(prev, next, registry, alienEngine) + + // Only counter1.value should have changed + expect(c1ValueSig.get()).toBe(1) + + // Everything else should be unchanged + expect(todoTextSig.get()).toBe(initialTodoText) + expect(c2ValueSig.get()).toBe(initialC2Value) + expect(filterSig.get()).toBe(initialFilter) + }) + }) + + describe('edge cases', () => { + it('handles empty objects', () => { + const prev = {} + const next = { a: 1 } + const registry = createPathSignalRegistry(alienEngine) + + expect(() => diffAndUpdateSignals(prev, next, '', registry)).not.toThrow() + // Nothing was tracked, so nothing should be created + expect(registry.size()).toBe(0) + }) + + it('handles root-level primitive comparison', () => { + // Edge case: comparing primitives at root level + const registry = createPathSignalRegistry(alienEngine) + const rootSig = registry.getOrCreate('root', 'old') + + expect(() => + diffAndUpdateSignals('old', 'new', 'root', registry), + ).not.toThrow() + // A tracked signal at the offset path gets the new primitive value + expect(rootSig.get()).toBe('new') + }) + + it('handles nested path offset correctly', () => { + const prev = { value: 10 } + const next = { value: 20 } + + const registry = createPathSignalRegistry(alienEngine) + const valueSig = registry.getOrCreate('nested.obj.value', 10) + + // Diff starting from a path offset + diffAndUpdateSignals(prev, next, 'nested.obj', registry) + + expect(valueSig.get()).toBe(20) + }) + }) +}) diff --git a/test/signals/edgeCases.spec.ts b/test/signals/edgeCases.spec.ts new file mode 100644 index 000000000..23ff681e9 --- /dev/null +++ b/test/signals/edgeCases.spec.ts @@ -0,0 +1,521 @@ +/** + * Edge case audit tests (unit level: proxy / registry / diff). + * + * Tests marked `it.fails` assert the DESIRED behavior for confirmed bugs — + * they pass while the bug exists and will flip to failing once the bug is + * fixed (at which point remove the `.fails` marker). + * + * Tests named "documented limitation: ..." assert CURRENT behavior that we + * accept and document rather than fix. + * + * See dev-plans research doc: 2026-07-28-edge-case-audit.md + */ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createLeafTracker, + createTrackingProxy, +} from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' +import { buildIdentityPath } from '../../src/signals/arrayKeys' + +const createTracker = createLeafTracker + +describe('edge cases: non-plain objects in state', () => { + // Non-plain objects (Date, Map, Set, class instances) are opaque leaves: + // the proxy returns them raw (no shell, so internal-slot methods work) + // and tracks them by reference via a version signal at their path. + + it('supports calling Date methods on a state Date', () => { + const createdAt = new Date(2020, 0, 1) + const state = { createdAt } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.createdAt.getTime()).toBe(createdAt.getTime()) + }) + + it('supports calling Map methods on a state Map', () => { + const state = { lookup: new Map([['a', 1]]) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.lookup.get('a')).toBe(1) + }) + + it('supports calling Set methods on a state Set', () => { + const state = { tags: new Set(['x']) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.tags.has('x')).toBe(true) + }) + + it('instanceof checks work through the proxy (getPrototypeOf trap)', () => { + const state = { createdAt: new Date(2020, 0, 1) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.createdAt instanceof Date).toBe(true) + }) + + it('reads Map.size through the proxy (getter invoked on the real target)', () => { + const state = { lookup: new Map([['a', 1]]) } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.lookup.size).toBe(1) + }) + + it('documented limitation: class instances are opaque leaves tracked by reference', () => { + class User { + constructor(public name: string) {} + } + const alice = new User('Alice') + const prev = { user: alice } + const next = { user: new User('Bob') } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + + // The instance comes back raw — no proxy shell, no child signals. + expect(proxy.user).toBe(alice) + expect(proxy.user.name).toBe('Alice') + expect(registry.has('user.name')).toBe(false) + expect(registry.has('user')).toBe(true) + + // Replacing the instance bumps the reference signal at 'user'. + // Mutating the instance in place would NOT be detected — documented + // guidance: keep state to plain objects and arrays. + let runs = 0 + const computed = alienEngine.computed(() => { + runs++ + const p = createTrackingProxy(next, '', registry, registry.proxyCache) + return p.user.name + }) + expect(computed.get()).toBe('Bob') + expect(runs).toBe(1) + + reconcileState(prev, next, registry, alienEngine) + expect(computed.get()).toBe('Bob') + expect(runs).toBe(2) + }) +}) + +describe('edge cases: identity key values', () => { + it('numeric and string key values produce distinct identity paths', () => { + // {id: 1} builds "items.{id:1}" while {id: "1"} builds 'items.{id:"1"}' — + // numeric-looking string keys are quoted to avoid colliding. + expect(buildIdentityPath('items', 'id', 1)).not.toBe( + buildIdentityPath('items', 'id', '1'), + ) + }) + + it('literal quotes in key values stay distinct from the quoted numeric form', () => { + // string '"1"' must not render identically to string '1' (which gets + // quote-wrapped as {id:"1"}). Literal quotes are escaped as %22. + expect(buildIdentityPath('items', 'id', '"1"')).not.toBe( + buildIdentityPath('items', 'id', '1'), + ) + expect(buildIdentityPath('items', 'id', '"1"')).toBe('items.{id:%221%22}') + }) + + it('key values containing dots do not corrupt the prefix index', () => { + // Emails and composite keys as ids are common. Prefix bookkeeping + // splits on '.', so dots (and %, {, }, ", @) in key values are + // percent-escaped: "users.{id:a%40b%2Ecom}.name". + const state = { users: [{ id: 'a@b.com', name: 'Alice' }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.users[0].name).toBe('Alice') + expect(registry.hasPrefix('users.{id:a@b')).toBe(false) + }) + + it('removing an entity whose id contains dots still prunes its child signals', () => { + const prev = { users: [{ id: 'a@b.com', name: 'Alice' }] } + const next = { users: [] as { id: string; name: string }[] } + const identityPath = buildIdentityPath('users', 'id', 'a@b.com') + expect(identityPath).toBe('users.{id:a%40b%2Ecom}') + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.users[0].name).toBe('Alice') + expect(registry.has(`${identityPath}.name`)).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has(`${identityPath}.name`)).toBe(false) + }) +}) + +describe('edge cases: object keys with reserved path characters', () => { + // Object property keys are encoded with the same %-escaping as identity + // key values. Without encoding, state like { 'a.b': 1, a: { b: 2 } } + // maps both locations to the path string "a.b" — updates clobber each + // other's signal and one side goes permanently stale. + + it('a dotted key and a nested path do not share a signal', () => { + const prev = { 'a.b': 1, a: { b: 2 } } + const registry = createPathSignalRegistry(alienEngine) + let current: typeof prev = prev + + let dottedRuns = 0 + const dotted = alienEngine.computed(() => { + dottedRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p['a.b'] + }) + let nestedRuns = 0 + const nested = alienEngine.computed(() => { + nestedRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p.a.b + }) + expect(dotted.get()).toBe(1) + expect(nested.get()).toBe(2) + + // Change ONLY the nested a.b — the dotted key's computed must not + // re-run, and its value must survive. + const next = { 'a.b': 1, a: { b: 99 } } + current = next + reconcileState(prev, next, registry, alienEngine) + expect(nested.get()).toBe(99) + expect(nestedRuns).toBe(2) + expect(dotted.get()).toBe(1) + expect(dottedRuns).toBe(1) + + // Now change ONLY the dotted key — this was the permanently-stale + // case before encoding (the signal already held 99, so the real + // change no-opped). + const final = { 'a.b': 42, a: { b: 99 } } + current = final + reconcileState(next, final, registry, alienEngine) + expect(dotted.get()).toBe(42) + expect(dottedRuns).toBe(2) + expect(nested.get()).toBe(99) + expect(nestedRuns).toBe(2) + }) + + it('RTK Query style cache keys with dots and quotes stay reactive', () => { + const key = 'getUser("a.b@c.com")' + const prev = { queries: { [key]: { status: 'pending' } } } + const next = { queries: { [key]: { status: 'fulfilled' } } } + + const registry = createPathSignalRegistry(alienEngine) + let runs = 0 + const computed = alienEngine.computed(() => { + runs++ + const p = createTrackingProxy( + runs === 1 ? prev : next, + '', + registry, + registry.proxyCache, + ) + return p.queries[key].status + }) + expect(computed.get()).toBe('pending') + + reconcileState(prev, next, registry, alienEngine) + expect(computed.get()).toBe('fulfilled') + expect(runs).toBe(2) + }) + + it('a state key literally named "@@keys" does not collide with the keys meta signal', () => { + const prev = { obj: { '@@keys': 'v1', x: 1 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + + expect(proxy.obj['@@keys']).toBe('v1') + // The literal key's signal lives at the encoded path. + expect(registry.has('obj.%40%40keys')).toBe(true) + expect(registry.has('obj.@@keys')).toBe(false) + + // Iterating keys registers the meta signal at the raw path. + expect(Object.keys(proxy.obj)).toEqual(['@@keys', 'x']) + expect(registry.has('obj.@@keys')).toBe(true) + + // Updating the literal '@@keys' property fires its own signal, and + // the key set is unchanged, so the meta signal must not fire. + let current = prev + let literalRuns = 0 + const literal = alienEngine.computed(() => { + literalRuns++ + const p = createTrackingProxy(current, '', registry, registry.proxyCache) + return p.obj['@@keys'] + }) + expect(literal.get()).toBe('v1') + + const next = { obj: { '@@keys': 'v2', x: 1 } } + current = next + reconcileState(prev, next, registry, alienEngine) + expect(literal.get()).toBe('v2') + expect(literalRuns).toBe(2) + }) + + it('a state key shaped like an identity segment does not collide with identity paths', () => { + const state = { + items: [{ id: 1, name: 'real' }], + lookup: { '{id:1}': 'literal' }, + } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.items[0].name).toBe('real') + expect(proxy.lookup['{id:1}']).toBe('literal') + expect(registry.has('lookup.%7Bid:1%7D')).toBe(true) + expect(registry.has('lookup.{id:1}')).toBe(false) + }) + + it('column signals encode scanned property names', () => { + const state = { rows: [{ id: 1, 'a.b': 'x' }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const found = proxy.rows.find((r) => r['a.b'] === 'x') + expect(found).toBeDefined() + expect(registry.has('rows.{*}.a%2Eb')).toBe(true) + expect(registry.has('rows.{*}.a.b')).toBe(false) + }) +}) + +describe('edge cases: same-length keyed array replacement (remove + add)', () => { + // diffArrayByKey's general path tracks seen keys unconditionally, so a + // same-length replacement (remove one entity, add another) prunes the + // removed entity's signals even though the array length is unchanged. + + it('prunes signals for an entity removed in a same-length replacement', () => { + const shared = { id: 1, text: 'first' } + const prev = { items: [shared, { id: 2, text: 'second' }] } + const next = { items: [shared, { id: 9, text: 'ninth' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[0].text).toBe('first') + expect(proxy.items[1].text).toBe('second') + expect(registry.has('items.{id:2}.text')).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has('items.{id:2}.text')).toBe(false) + }) + + it('temp-id → server-id swap prunes the temp entity signals', () => { + // Optimistic update pattern: entity created with a temp id, server + // responds with the real id. Same array length, key value changes. + const prev = { items: [{ id: 'temp-1', text: 'draft' }] } + const next = { items: [{ id: 42, text: 'draft' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[0].text).toBe('draft') + expect(registry.has('items.{id:temp-1}.text')).toBe(true) + + reconcileState(prev, next, registry, alienEngine) + + expect(registry.has('items.{id:temp-1}.text')).toBe(false) + }) + + it('fires the structural remove signal for a same-length replacement', () => { + // The structural classification is computed independently of the prune + // bookkeeping, so scans (filter/find/...) DO see the removal. + const shared = { id: 1, text: 'first' } + const prev = { items: [shared, { id: 2, text: 'second' }] } + const next = { items: [shared, { id: 9, text: 'ninth' }] } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + expect(proxy.items[1].text).toBe('second') + + const removeSig = registry.trackStructure('items', 'remove') + const before = removeSig.get() + + reconcileState(prev, next, registry, alienEngine) + + expect(removeSig.get()).not.toBe(before) + }) +}) + +describe('edge cases: aliased references (same object at two paths)', () => { + // DOCUMENTED LIMITATION (deliberate tradeoff): the proxyCache is keyed by + // target object identity only. When the same raw object is reachable at + // two paths (state.selected = state.items[0]), whichever path is proxied + // first wins — reads through the second path register signals under the + // FIRST path. Per-path proxies would fix the attribution but break + // `state.selected === state.items[0]` identity checks, which the system + // guarantees. The leaf-object tracker keeps correctness: the alias path + // gets a version-signal dependency, so replacement still re-runs the + // selector (see next test). + + it.fails('reads through a second path register signals under that path', () => { + const item = { id: 1, name: 'a' } + const state = { items: [item], selected: item } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Traverse via items first — item's proxy is cached with path "items.{id:1}" + expect(proxy.items[0].name).toBe('a') + // Now read the same object via selected — cache hit returns the + // items-path proxy, so this registers "items.{id:1}.name" again + // instead of "selected.name". + expect(proxy.selected.name).toBe('a') + + expect(registry.has('selected.name')).toBe(true) + }) + + it('leaf-object tracking still records the aliased path itself (coarse rescue)', () => { + // The alias path IS recorded as a leaf object access (the child reads + // go under the wrong path, but 'selected' itself lands in the tracker + // untraversed). The hook then subscribes to the 'selected' version + // signal, so *replacement* of the aliased ref still re-runs the + // selector — precision is lost, correctness mostly survives. + const item = { id: 1, name: 'a' } + const state = { items: [item], selected: item } + + const registry = createPathSignalRegistry(alienEngine) + const tracker = createTracker() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + tracker, + ) + + expect(proxy.items[0].name).toBe('a') + expect(proxy.selected.name).toBe('a') + + expect(tracker.accessedObjects.has('selected')).toBe(true) + expect(tracker.traversedPaths.has('selected')).toBe(false) + }) +}) + +describe('edge cases: leafTracker capture through the shared proxy cache', () => { + // The active leafTracker lives in registry.leafTrackerHolder and is + // swapped in at the start of each evaluation, so cached proxies created + // by an earlier evaluation record into the CURRENT evaluation's tracker. + + it('a second evaluation records leaf accesses into its own tracker', () => { + const state = { settings: { theme: 'dark' } } + const registry = createPathSignalRegistry(alienEngine) + + // Evaluation A creates and caches all the proxies + const trackerA = createTracker() + const proxyA = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + trackerA, + ) + expect(proxyA.settings.theme).toBe('dark') + + // Evaluation B gets cache hits — every trap still records into trackerA + const trackerB = createTracker() + const proxyB = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + trackerB, + ) + // Leaf (identity-only) access by evaluation B + void proxyB.settings + + expect(trackerB.accessedObjects.has('settings')).toBe(true) + }) +}) + +describe('edge cases: registry lifecycle', () => { + it('prune clears array identity metadata (entityMap retention)', () => { + const state = { items: [{ id: 1, v: 1 }] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Element access detects the key field and creates ArrayMeta + expect(proxy.items[0].v).toBe(1) + expect(registry.getArrayMeta('items')).toBeDefined() + + registry.prune('items') + + // The entityMap holds strong refs to old entities — after a reset + // action prunes the slice, the metadata should be released too. + expect(registry.getArrayMeta('items')).toBeUndefined() + }) +}) + +describe('edge cases: NaN leaf values', () => { + it('does not re-run a computed when an untouched NaN leaf is re-diffed', () => { + // The diff's skip checks use Object.is semantics for NaN (self-inequal + // values on both sides are treated as equal), so an untouched NaN leaf + // no longer re-fires its signal on every dispatch that touches the + // parent object. + const prev = { metrics: { ratio: NaN, count: 1 } } + const next = { metrics: { ratio: NaN, count: 2 } } + + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(prev, '', registry, registry.proxyCache) + + let runs = 0 + const c = alienEngine.computed(() => { + runs++ + return proxy.metrics.ratio + }) + expect(Number.isNaN(c.get() as number)).toBe(true) + expect(runs).toBe(1) + + reconcileState(prev, next, registry, alienEngine) + + c.get() + expect(runs).toBe(1) + }) +}) + +describe('edge cases: common selector patterns (smoke)', () => { + it('JSON.stringify over a proxied subtree matches the raw state', () => { + const state = { + settings: { theme: 'dark', flags: { beta: true }, list: [1, 2, 3] }, + } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(JSON.stringify(proxy.settings)).toBe(JSON.stringify(state.settings)) + }) + + it('Object.values / Object.entries work through the proxy', () => { + const state = { counts: { a: 1, b: 2 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(Object.values(proxy.counts)).toEqual([1, 2]) + expect(Object.entries(proxy.counts)).toEqual([ + ['a', 1], + ['b', 2], + ]) + }) + + it('destructuring a proxied object reads tracked values', () => { + const state = { settings: { theme: 'dark', fontSize: 12 } } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const { theme, fontSize } = proxy.settings + expect(theme).toBe('dark') + expect(fontSize).toBe(12) + expect(registry.has('settings.theme')).toBe(true) + expect(registry.has('settings.fontSize')).toBe(true) + }) + + it('reading a missing index of a primitive array subscribes coarsely and returns undefined', () => { + const state = { ids: [1, 2, 3] } + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.ids[5]).toBeUndefined() + // Coarse array signal, not a per-index signal + expect(registry.has('ids')).toBe(true) + expect(registry.has('ids.5')).toBe(false) + }) +}) diff --git a/test/signals/edgeCasesReact.spec.tsx b/test/signals/edgeCasesReact.spec.tsx new file mode 100644 index 000000000..59c6fb51c --- /dev/null +++ b/test/signals/edgeCasesReact.spec.tsx @@ -0,0 +1,553 @@ +/** + * Edge case audit tests (React integration level). + * + * Tests marked `it.fails` assert the DESIRED behavior for confirmed bugs — + * they pass while the bug exists and will flip to failing once the bug is + * fixed (at which point remove the `.fails` marker). + * + * See dev-plans research doc: 2026-07-28-edge-case-audit.md + */ +import * as rtl from '@testing-library/react' +import React, { StrictMode, useLayoutEffect, useState } from 'react' +import { renderToString } from 'react-dom/server' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector, unwrap } from '../../src/signals' + +interface Counter { + id: string + label: string + value: number +} + +const countersSlice = createSlice({ + name: 'counters', + initialState: { + counter1: { id: 'counter1', label: 'Clicks', value: 0 } as Counter, + counter2: { id: 'counter2', label: 'Views', value: 42 } as Counter, + }, + reducers: { + increment(state, action: PayloadAction<'counter1' | 'counter2'>) { + state[action.payload].value += 1 + }, + }, +}) + +const filterSlice = createSlice({ + name: 'filter', + initialState: 'all' as 'all' | 'active' | 'completed', + reducers: { + set(_state, action: PayloadAction<'all' | 'active' | 'completed'>) { + return action.payload + }, + }, +}) + +const settingsSlice = createSlice({ + name: 'settings', + initialState: { theme: 'dark', fontSize: 12 }, + reducers: { + setFontSize(state, action: PayloadAction) { + state.fontSize = action.payload + }, + setTheme(state, action: PayloadAction) { + state.theme = action.payload + }, + }, +}) + +function createTestStore() { + return configureStore({ + reducer: { + counters: countersSlice.reducer, + filter: filterSlice.reducer, + settings: settingsSlice.reducer, + }, + }) +} + +type TestStore = ReturnType +type TestState = ReturnType + +describe('edge cases: React integration', () => { + let store: TestStore + + beforeEach(() => { + store = createTestStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + describe('selector closing over props', () => { + // A render that brings a different selector function triggers a + // render-phase swap: the computed is invalidated and re-evaluated in + // place, so getSnapshot returns the new closure's value in the same + // render (same approach as useSyncExternalStoreWithSelector). + it('re-selects when a prop used by the selector changes', () => { + function CounterValue({ which }: { which: 'counter1' | 'counter2' }) { + const value = useSignalSelector( + (s: TestState) => s.counters[which].value, + ) + return
{value}
+ } + + let setWhich: (w: 'counter1' | 'counter2') => void = () => {} + function Parent() { + const [which, set] = useState<'counter1' | 'counter2'>('counter1') + setWhich = set + return + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('value').textContent).toBe('0') + + rtl.act(() => { + setWhich('counter2') + }) + + // counter2's value is 42 — the stale closure keeps showing counter1's 0 + expect(getByTestId('value').textContent).toBe('42') + }) + }) + + describe('root identity selector', () => { + // The root proxy's path is '' and the diff never updates the '' path, + // so `s => s` establishes no firing dependency. + it.fails('state => state re-renders on dispatch', () => { + function WholeState() { + const state = useSignalSelector((s: TestState) => s) + return
{state.filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + }) + + describe('identity comparison across multiple components', () => { + it('control: a lone component tracks identity changes of a leaf object', () => { + const originalSettings = store.getState().settings + + function IdentityWatcher() { + const result = useSignalSelector((s: TestState) => + unwrap(s.settings) === originalSettings ? 'same' : 'changed', + ) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('identity').textContent).toBe('same') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(99)) + }) + + expect(getByTestId('identity').textContent).toBe('changed') + }) + + // The active leafTracker lives in registry.leafTrackerHolder and is + // swapped in per evaluation, so component B's identity-only reads land + // in B's own tracker even when component A's earlier traversal already + // populated the proxy cache. + it('a second component still tracks identity changes when another component traversed the same slice first', () => { + const originalSettings = store.getState().settings + + function ThemeReader() { + const theme = useSignalSelector((s: TestState) => s.settings.theme) + return
{theme}
+ } + + function IdentityWatcher() { + const result = useSignalSelector((s: TestState) => + unwrap(s.settings) === originalSettings ? 'same' : 'changed', + ) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + {/* A mounts first, creating + caching the proxies with its tracker */} + + + , + ) + + expect(getByTestId('identity').textContent).toBe('same') + + // Changes fontSize only — theme untouched, settings ref replaced + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(99)) + }) + + expect(getByTestId('identity').textContent).toBe('changed') + }) + }) + + describe('StrictMode', () => { + it('renders and updates correctly under StrictMode', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + }) + + describe('re-entrant dispatch', () => { + it('handles a component dispatching from a layout effect in response to a change', () => { + function ChainedDispatcher() { + const value = useSignalSelector( + (s: TestState) => s.counters.counter1.value, + ) + useLayoutEffect(() => { + if (value === 1) { + store.dispatch(countersSlice.actions.increment('counter1')) + } + }, [value]) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('chained').textContent).toBe('0') + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // The layout effect chains a second increment: 0 → 1 → 2 + expect(getByTestId('chained').textContent).toBe('2') + }) + + it('handles a store subscriber dispatching during notification', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const unsubscribe = store.subscribe(() => { + if (store.getState().filter === 'active') { + store.dispatch(filterSlice.actions.set('completed')) + } + }) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('completed') + unsubscribe() + }) + }) + + describe('selector errors', () => { + class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } + > { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return
{this.state.error.message}
+ } + return this.props.children + } + } + + it('recovers when a throwing evaluation is superseded before React renders', () => { + // Two dispatches in one act: the first makes the selector throw + // during the effect's re-evaluation, the second fixes the state. + // The successful re-run must clear the pending error so the + // render that follows never sees it. + let throwCount = 0 + function FilterReader() { + const filter = useSignalSelector((s: TestState) => { + if (s.filter === 'active') { + throwCount++ + throw new Error('transient selector failure') + } + return s.filter + }) + return
{filter}
+ } + + const { getByTestId, queryByTestId } = rtl.render( + + + + + , + ) + expect(getByTestId('filter').textContent).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + store.dispatch(filterSlice.actions.set('completed')) + }) + + // The throw really happened — recovery is what is being tested, + // not a selector that never saw the bad state. + expect(throwCount).toBeGreaterThan(0) + expect(queryByTestId('boundary')).toBeNull() + expect(getByTestId('filter').textContent).toBe('completed') + }) + + it('recovers when a store subscriber corrects the throwing state during notification', () => { + // Same retry leg, driven re-entrantly: the subscriber sees the + // bad state mid-notification and dispatches the fix before React + // gets to render. + let throwCount = 0 + function FilterReader() { + const filter = useSignalSelector((s: TestState) => { + if (s.filter === 'active') { + throwCount++ + throw new Error('transient selector failure') + } + return s.filter + }) + return
{filter}
+ } + + const { getByTestId, queryByTestId } = rtl.render( + + + + + , + ) + + // Promote out of the coarse tier first so the deep effect + // re-evaluates the selector synchronously on each dispatch — + // otherwise the hook never observes the intermediate state. + rtl.act(() => { + store.dispatch(filterSlice.actions.set('completed')) + }) + expect(getByTestId('filter').textContent).toBe('completed') + + const unsubscribe = store.subscribe(() => { + if (store.getState().filter === 'active') { + store.dispatch(filterSlice.actions.set('all')) + } + }) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(throwCount).toBeGreaterThan(0) + expect(queryByTestId('boundary')).toBeNull() + expect(getByTestId('filter').textContent).toBe('all') + unsubscribe() + }) + }) + + describe('dispatch during a selector', () => { + it('a selector that dispatches once during evaluation settles on the final state', () => { + // Dispatching from a selector is an application bug, but it must + // not corrupt the graph or loop forever — the guarded dispatch + // lands and every hook settles on the post-dispatch state. + let dispatched = false + + function GreedyReader() { + const value = useSignalSelector((s: TestState) => { + if (!dispatched && s.counters.counter1.value === 1) { + dispatched = true + store.dispatch(countersSlice.actions.increment('counter1')) + } + return s.counters.counter1.value + }) + return
{value}
+ } + + function OtherReader() { + const value = useSignalSelector( + (s: TestState) => s.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('greedy').textContent).toBe('0') + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('greedy').textContent).toBe('2') + expect(getByTestId('other').textContent).toBe('2') + }) + }) + + describe('server-side rendering', () => { + // useSyncExternalStore is called without a getServerSnapshot argument, + // so any server render (Next.js etc.) throws. + it('supports renderToString', () => { + function FilterReader() { + const filter = useSignalSelector((s: TestState) => s.filter) + return
{filter}
+ } + + const html = renderToString( + + + , + ) + + expect(html).toContain('all') + }) + }) + + describe('conditional reads', () => { + // Dependencies are rebuilt from scratch on every evaluation, so a + // branch that was not read the first time still becomes a dependency + // as soon as the selector starts reading it. + it('tracks the branch that is read after a toggle flips', () => { + let selectorRuns = 0 + let renders = 0 + const selectByFilter = (s: TestState) => { + selectorRuns++ + return s.filter === 'all' + ? s.counters.counter1.value + : s.counters.counter2.value + } + function Value() { + renders++ + const value = useSignalSelector(selectByFilter) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('0') + expect(renders).toBe(1) + + // counter2 is not read yet: no evaluation, no render. + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter2')) + }) + expect(getByTestId('value').textContent).toBe('0') + expect(renders).toBe(1) + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + expect(getByTestId('value').textContent).toBe('43') + expect(renders).toBe(2) + + // Now counter2 IS a dependency and counter1 no longer is. + const runsBefore = selectorRuns + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter2')) + }) + expect(getByTestId('value').textContent).toBe('44') + expect(renders).toBe(3) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + expect(getByTestId('value').textContent).toBe('44') + expect(renders).toBe(3) + expect(selectorRuns).toBe(runsBefore + 1) + }) + + it('drops a dependency that an early return stops reading', () => { + let selectorRuns = 0 + function Value() { + const value = useSignalSelector((s: TestState) => { + selectorRuns++ + if (s.settings.theme === 'light') return 'light mode' + return `${s.settings.theme}/${s.settings.fontSize}` + }) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('value').textContent).toBe('dark/12') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(14)) + }) + expect(getByTestId('value').textContent).toBe('dark/14') + + rtl.act(() => { + store.dispatch(settingsSlice.actions.setTheme('light')) + }) + expect(getByTestId('value').textContent).toBe('light mode') + + // fontSize is no longer read past the early return, so changing it + // must not evaluate the selector at all. + const runsBefore = selectorRuns + rtl.act(() => { + store.dispatch(settingsSlice.actions.setFontSize(16)) + }) + expect(getByTestId('value').textContent).toBe('light mode') + expect(selectorRuns).toBe(runsBefore) + }) + }) +}) diff --git a/test/signals/engine.spec.ts b/test/signals/engine.spec.ts new file mode 100644 index 000000000..c9f9e62e6 --- /dev/null +++ b/test/signals/engine.spec.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi } from 'vitest' +import { alienEngine } from '../../src/signals/engine' + +describe('SignalEngine (alien-signals)', () => { + describe('signal', () => { + it('reads initial value', () => { + const s = alienEngine.signal(42) + expect(s.get()).toBe(42) + }) + + it('writes and reads new value', () => { + const s = alienEngine.signal(0) + s.set(10) + expect(s.get()).toBe(10) + }) + + it('stores different value types', () => { + const sNum = alienEngine.signal(1) + const sStr = alienEngine.signal('hello') + const sBool = alienEngine.signal(true) + const sNull = alienEngine.signal(null) + + expect(sNum.get()).toBe(1) + expect(sStr.get()).toBe('hello') + expect(sBool.get()).toBe(true) + expect(sNull.get()).toBe(null) + }) + }) + + describe('computed', () => { + it('derives value from signal', () => { + const s = alienEngine.signal(5) + const c = alienEngine.computed(() => s.get() * 2) + expect(c.get()).toBe(10) + }) + + it('updates when dependency changes', () => { + const s = alienEngine.signal(1) + const c = alienEngine.computed(() => s.get() + 100) + expect(c.get()).toBe(101) + s.set(2) + expect(c.get()).toBe(102) + }) + + it('provides propagation cutoff (same computed value does not notify)', () => { + const s = alienEngine.signal(5) + const c = alienEngine.computed(() => s.get() > 3) // true for 5 + + let effectCount = 0 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + c.get() + effectCount++ + }) + }) + effectCount = 0 + + s.set(6) // still > 3 → computed returns true → no change + expect(effectCount).toBe(0) + + s.set(2) // now ≤ 3 → computed returns false → change + expect(effectCount).toBe(1) + + scope.stop() + }) + + it('chains multiple computeds', () => { + const s = alienEngine.signal(2) + const c1 = alienEngine.computed(() => s.get() * 3) + const c2 = alienEngine.computed(() => c1.get() + 1) + expect(c2.get()).toBe(7) + s.set(4) + expect(c2.get()).toBe(13) + }) + }) + + describe('effect', () => { + it('runs immediately on creation (within scope)', () => { + const effectFn = vi.fn() + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(effectFn) + }) + expect(effectFn).toHaveBeenCalledTimes(1) + scope.stop() + }) + + it('re-runs when tracked signals change', () => { + const s = alienEngine.signal(0) + let lastSeen = -1 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + lastSeen = s.get() + }) + }) + expect(lastSeen).toBe(0) + + s.set(5) + expect(lastSeen).toBe(5) + + scope.stop() + }) + + it('stops running after scope is disposed', () => { + const s = alienEngine.signal(0) + let effectCount = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s.get() + effectCount++ + }) + }) + expect(effectCount).toBe(1) + + scope.stop() + s.set(10) + expect(effectCount).toBe(1) // should not have run again + }) + }) + + describe('batch', () => { + it('coalesces multiple signal updates into one propagation', () => { + const s1 = alienEngine.signal(0) + const s2 = alienEngine.signal(0) + const c = alienEngine.computed(() => s1.get() + s2.get()) + + let effectCount = 0 + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + c.get() + effectCount++ + }) + }) + effectCount = 0 + + alienEngine.batch(() => { + s1.set(1) + s2.set(1) + }) + + expect(effectCount).toBe(1) // single propagation, not 2 + expect(c.get()).toBe(2) + + scope.stop() + }) + + it('handles nested batch correctly', () => { + const s = alienEngine.signal(0) + let effectCount = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s.get() + effectCount++ + }) + }) + effectCount = 0 + + alienEngine.batch(() => { + s.set(1) + alienEngine.batch(() => { + s.set(2) + }) + s.set(3) + }) + + // Only the final value should be seen after batch + expect(s.get()).toBe(3) + // Nested batches coalesce into a single propagation, not 3 + expect(effectCount).toBe(1) + + scope.stop() + }) + }) + + describe('createScope', () => { + it('returns value from run()', () => { + const scope = alienEngine.createScope() + const result = scope.run(() => 42) + expect(result).toBe(42) + scope.stop() + }) + + it('disposes all effects when stopped', () => { + const s1 = alienEngine.signal(0) + const s2 = alienEngine.signal(0) + let effect1Count = 0 + let effect2Count = 0 + + const scope = alienEngine.createScope() + scope.run(() => { + alienEngine.effect(() => { + s1.get() + effect1Count++ + }) + alienEngine.effect(() => { + s2.get() + effect2Count++ + }) + }) + expect(effect1Count).toBe(1) + expect(effect2Count).toBe(1) + + scope.stop() + s1.set(1) + s2.set(1) + expect(effect1Count).toBe(1) // not re-run + expect(effect2Count).toBe(1) // not re-run + }) + }) +}) diff --git a/test/signals/enumerationCollapse.spec.tsx b/test/signals/enumerationCollapse.spec.tsx new file mode 100644 index 000000000..301a877bf --- /dev/null +++ b/test/signals/enumerationCollapse.spec.tsx @@ -0,0 +1,640 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import * as rtl from '@testing-library/react' +import { configureStore, createSlice } from '@reduxjs/toolkit' +import { Provider, useSelector, shallowEqual } from 'react-redux' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createLeafTracker, + createTrackingProxy, + finalizeDependencies, + getProxyPath, +} from '../../src/signals/trackingProxy' +import { reconcileState } from '../../src/signals/diff' +import { keysMetaPath } from '../../src/signals/arrayKeys' +import { useSignalContext } from '../../src/signals/context' + +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + +// Enumerating a plain object and then reading every own value (spread, +// Object.values/entries, JSON.stringify, shallowEqual, for...in) depends on +// exactly what the object's identity depends on under immutable updates. +// The tracking proxy defers those reads and collapses a complete +// enumeration onto the object's version signal instead of one signal per +// key plus the keys-meta signal. Incomplete enumerations replay the +// deferred reads so precision is unchanged. + +interface Address { + city: string + zip: string +} + +interface User { + name: string + age: number + address: Address + joined: Date +} + +interface Item { + id: number + text: string + done: boolean +} + +interface State { + user: User + other: User + counter: number + items: Item[] +} + +function makeState(): State { + return { + user: { + name: 'Alice', + age: 30, + address: { city: 'Springfield', zip: '11111' }, + joined: new Date(2020, 0, 1), + }, + other: { + name: 'Bob', + age: 41, + address: { city: 'Shelbyville', zip: '22222' }, + joined: new Date(2021, 0, 1), + }, + counter: 0, + items: [ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ], + } +} + +function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) +} + +function mount( + getState: () => State, + selector: (state: State) => R, + registry: PathSignalRegistry, +) { + let evals = 0 + const scope = alienEngine.createScope() + const computed = scope.run(() => + alienEngine.computed(() => { + evals++ + const leafTracker = createLeafTracker() + const proxy = createTrackingProxy( + getState(), + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as State) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) registry.getOrCreate(proxyPath, result).get() + finalizeDependencies(leafTracker, registry) + return result + }), + ) + return { computed, evals: () => evals, stop: () => scope.stop() } +} + +function harness(selector: (state: State) => R) { + let state = makeState() + const registry = makeRegistry() + const m = mount(() => state, selector, registry) + const dispatch = (recipe: (prev: State) => State) => { + const prev = state + state = recipe(prev) + reconcileState(prev, state, registry, alienEngine) + return m.computed.get() + } + return { registry, dispatch, ...m } +} + +const setUserName = (name: string) => (s: State) => ({ + ...s, + user: { ...s.user, name }, +}) +const setUserCity = (city: string) => (s: State) => ({ + ...s, + user: { ...s.user, address: { ...s.user.address, city } }, +}) +const setOtherName = (name: string) => (s: State) => ({ + ...s, + other: { ...s.other, name }, +}) +const bumpCounter = (s: State) => ({ ...s, counter: s.counter + 1 }) +const addUserKey = (s: State) => ({ + ...s, + user: { ...s.user, nickname: 'Al' } as User, +}) + +describe('enumeration collapse (unit)', () => { + describe('complete enumerations collapse onto the object version signal', () => { + const cases: Array<[string, (s: State) => unknown]> = [ + ['spread', (s) => ({ ...s.user })], + ['spread with extra field', (s) => ({ ...s.user, extra: 1 })], + ['Object.values', (s) => Object.values(s.user)], + ['Object.entries', (s) => Object.entries(s.user)], + ['Object.assign', (s) => Object.assign({}, s.user)], + ['JSON.stringify', (s) => JSON.stringify(s.user)], + [ + 'for...in reading each key', + (s) => { + const out: Record = {} + for (const k in s.user) out[k] = s.user[k as keyof User] + return out + }, + ], + [ + 'Object.keys().map(k => obj[k])', + (s) => Object.keys(s.user).map((k) => s.user[k as keyof User]), + ], + ] + + for (const [label, selector] of cases) { + it(`${label}: one signal for the object, none for its keys`, () => { + const h = harness(selector) + h.computed.get() + expect(h.evals()).toBe(1) + + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + expect(paths).not.toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain('user.address') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address.city') + h.stop() + }) + + it(`${label}: re-runs on any change to the object, not on siblings`, () => { + const h = harness(selector) + h.computed.get() + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch(setOtherName('Robert')) + expect(h.evals()).toBe(1) + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.dispatch(addUserKey) + expect(h.evals()).toBe(4) + h.stop() + }) + } + }) + + it('spread result reflects the new values after re-run', () => { + const h = harness((s) => ({ ...s.user })) + expect(h.computed.get().name).toBe('Alice') + // No prefix registered for the child object: the diff stops at `user` + // and fires its version signal. + expect(h.registry.hasPrefix('user.address')).toBe(false) + const next = h.dispatch(setUserName('Alicia')) + expect(next.name).toBe('Alicia') + expect(next.address.city).toBe('Springfield') + h.stop() + }) + + it('non-plain child (Date) counts toward completeness', () => { + const h = harness((s) => ({ ...s.user })) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + // The Date reference read stays immediate (harmless duplicate). + expect(paths).toContain('user.joined') + + h.dispatch((s) => ({ + ...s, + user: { ...s.user, joined: new Date(2022, 5, 5) }, + })) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('empty object spread collapses and fires when a key is added', () => { + let state = { ...makeState(), user: {} as User } + const registry = makeRegistry() + const m = mount( + () => state, + (s) => ({ ...s.user }), + registry, + ) + m.computed.get() + expect(registry.debugPaths()).toContain('user') + expect(registry.debugPaths()).not.toContain(keysMetaPath('user')) + + const prev = state + state = { ...state, user: { name: 'Zed' } as User } + reconcileState(prev, state, registry, alienEngine) + expect(m.computed.get()).toEqual({ name: 'Zed' }) + expect(m.evals()).toBe(2) + m.stop() + }) + + describe('incomplete enumerations replay the deferred reads', () => { + it('Object.keys().length: keys-meta signal only', () => { + const h = harness((s) => Object.keys(s.user).length) + expect(h.computed.get()).toBe(4) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).not.toContain('user.name') + expect(paths).not.toContain('user') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(1) + expect(h.dispatch(addUserKey)).toBe(5) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('reading a subset of keys: keys-meta plus only the leaves read', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k !== 'age' && k !== 'address' && k !== 'joined') + .map((k) => s.user[k as keyof User]), + ) + expect(h.computed.get()).toEqual(['Alice']) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain('user') + + h.dispatch((s) => ({ ...s, user: { ...s.user, age: 31 } })) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(1) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(addUserKey) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('subset including an object child: child is a leaf-object identity dep', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k === 'address') + .map((k) => s.user[k as keyof User]), + ) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain('user.address') + expect(paths).not.toContain('user.address.city') + expect(paths).not.toContain('user') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('subset including an object child that is then traversed: precise leaf dep', () => { + const h = harness((s) => + Object.keys(s.user) + .filter((k) => k === 'address') + .map((k) => (s.user[k as keyof User] as Address).city), + ) + expect(h.computed.get()).toEqual(['Springfield']) + + const paths = h.registry.debugPaths() + expect(paths).toContain('user.address.city') + expect(paths).not.toContain('user.address') + + h.dispatch((s) => ({ + ...s, + user: { ...s.user, address: { ...s.user.address, zip: '99999' } }, + })) + expect(h.evals()).toBe(1) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.stop() + }) + }) + + describe('mixed access in one evaluation', () => { + it('spread plus a deeper read: collapse is a superset, deep read still tracked', () => { + const h = harness((s) => ({ ...s.user, city: s.user.address.city })) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).toContain('user.address.city') + expect(paths).not.toContain('user.name') + + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.dispatch(bumpCounter) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('a plain field read before the spread still collapses', () => { + const h = harness((s) => { + const n = s.user.name + return { n, ...s.user } + }) + h.computed.get() + // The direct read registered `user.name` immediately; the repeat + // read during the spread still counts toward completeness. + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).toContain('user.name') + expect(paths).not.toContain('user.age') + expect(paths).not.toContain(keysMetaPath('user')) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('keys().length then spread of the same object collapses (complete overall)', () => { + const h = harness((s) => ({ + count: Object.keys(s.user).length, + ...s.user, + })) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user')) + h.dispatch(addUserKey) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('shallowEqual that returns early stays precise (incomplete enumeration)', () => { + // shallowEqual enumerates both objects but stops at the first key + // that differs (`name`), so neither enumeration is complete. + const h = harness((s) => shallowEqual(s.user, s.other)) + expect(h.computed.get()).toBe(false) + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('user')) + expect(paths).toContain(keysMetaPath('other')) + expect(paths).toContain('user.name') + expect(paths).toContain('other.name') + expect(paths).not.toContain('user') + expect(paths).not.toContain('other') + expect(paths).not.toContain('other.age') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ ...s, other: { ...s.other, age: 42 } })) + expect(h.evals()).toBe(1) + h.dispatch(setOtherName('Robert')) + expect(h.evals()).toBe(2) + h.stop() + }) + + it('shallowEqual over equal-shaped objects collapses both', () => { + let state = makeState() + state = { + ...state, + other: { ...state.other, address: { ...state.user.address } }, + } + const registry = makeRegistry() + const m = mount( + () => state, + (s) => shallowEqual(s.user.address, s.other.address), + registry, + ) + expect(m.computed.get()).toBe(true) + const paths = registry.debugPaths() + expect(paths).toContain('user.address') + expect(paths).toContain('other.address') + expect(paths).not.toContain('user.address.city') + expect(paths).not.toContain(keysMetaPath('user.address')) + m.stop() + }) + + it('nested enumeration under a collapsed parent adds no signals', () => { + // JSON.stringify enumerates `user` and then `user.address`. (Its + // `toJSON` probe on each object is an ordinary missing-key read and + // still registers a leaf for it; that is unrelated to enumeration.) + const h = harness((s) => JSON.stringify(s.user)) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain('user.address') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address.city') + h.stop() + }) + + it('child enumerated before its parent is still covered by the parent', () => { + const h = harness((s) => { + const n = Object.keys(s.user.address).length + return { ...s.user, n } + }) + h.computed.get() + const paths = h.registry.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain(keysMetaPath('user.address')) + expect(paths).not.toContain('user.address') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ + ...s, + user: { + ...s.user, + address: { ...s.user.address, country: 'US' } as Address, + }, + })) + expect(h.evals()).toBe(2) + h.stop() + }) + }) + + describe('exclusions', () => { + it('root spread keeps the immediate keys-meta read and per-key leaves', () => { + const h = harness((s) => ({ ...s })) + h.computed.get() + + const paths = h.registry.debugPaths() + expect(paths).toContain(keysMetaPath('')) + expect(paths).toContain('counter') + expect(paths).toContain('user') + expect(paths).not.toContain('') + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(2) + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('array spread keeps existing array tracking', () => { + const h = harness((s) => [...s.items]) + expect(h.computed.get()).toHaveLength(2) + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + h.dispatch((s) => ({ + ...s, + items: [...s.items, { id: 3, text: 'c', done: false }], + })) + expect(h.evals()).toBe(2) + h.dispatch((s) => ({ + ...s, + items: s.items.map((i) => (i.id === 1 ? { ...i, done: true } : i)), + })) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('spreading array elements inside map collapses onto element identity', () => { + const h = harness((s) => s.items.map((i) => ({ ...i }))) + expect(h.computed.get()).toEqual(makeState().items) + + const paths = h.registry.debugPaths() + expect(paths.some((p) => p.endsWith('.text'))).toBe(false) + expect(paths.some((p) => p.endsWith('.done'))).toBe(false) + + h.dispatch(bumpCounter) + expect(h.evals()).toBe(1) + const next = h.dispatch((s) => ({ + ...s, + items: s.items.map((i) => (i.id === 2 ? { ...i, text: 'B' } : i)), + })) + expect(h.evals()).toBe(2) + expect(next[1].text).toBe('B') + h.stop() + }) + }) + + describe('across evaluations', () => { + it('switching from spread to a single field drops the object dep', () => { + let mode: 'spread' | 'name' = 'spread' + const h = harness((s) => + mode === 'spread' ? { ...s.user } : s.user.name, + ) + h.computed.get() + expect(h.registry.debugPaths()).toContain('user') + + mode = 'name' + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + expect(h.registry.debugPaths()).toContain('user.name') + // Identity signal for `user` has no subscribers left and is released. + expect(h.registry.debugPaths()).not.toContain('user') + + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(2) + h.dispatch(setUserName('Alison')) + expect(h.evals()).toBe(3) + h.stop() + }) + + it('switching from a single field to spread picks up the object dep', () => { + let mode: 'spread' | 'name' = 'name' + const h = harness((s) => + mode === 'spread' ? { ...s.user } : s.user.name, + ) + h.computed.get() + + mode = 'spread' + h.dispatch(setUserName('Alicia')) + expect(h.evals()).toBe(2) + expect(h.registry.debugPaths()).toContain('user') + expect(h.registry.debugPaths()).not.toContain('user.name') + + h.dispatch(setUserCity('Ogdenville')) + expect(h.evals()).toBe(3) + h.stop() + }) + }) +}) + +// ─── Hook-level behavior (both TEST_IMPL modes) ─── + +const slice = createSlice({ + name: 'root', + initialState: makeState(), + reducers: { + renameUser(s, a: { payload: string }) { + s.user.name = a.payload + }, + renameOther(s, a: { payload: string }) { + s.other.name = a.payload + }, + increment(s) { + s.counter++ + }, + }, +}) + +function makeStore() { + return configureStore({ reducer: slice.reducer }) +} + +describe('enumeration collapse (hook)', () => { + it('spread selector with shallowEqual: renders only when the object changes', () => { + const store = makeStore() + const renders = { user: 0 } + let registry: PathSignalRegistry | undefined + + function CaptureRegistry() { + registry = useSignalContext().registry + return null + } + + function UserCard() { + renders.user++ + const user = useSelector((s: State) => ({ ...s.user }), shallowEqual) + return
{user.name}
+ } + + rtl.render( + + {IS_SIGNALS ? : null} + + , + ) + expect(rtl.screen.getByTestId('name').textContent).toBe('Alice') + expect(renders.user).toBe(1) + + rtl.act(() => { + store.dispatch(slice.actions.increment()) + store.dispatch(slice.actions.renameOther('Robert')) + }) + expect(renders.user).toBe(1) + + rtl.act(() => { + store.dispatch(slice.actions.renameUser('Alicia')) + }) + expect(rtl.screen.getByTestId('name').textContent).toBe('Alicia') + expect(renders.user).toBe(2) + + if (IS_SIGNALS) { + const paths = registry!.debugPaths() + expect(paths).toContain('user') + expect(paths).not.toContain('user.name') + expect(paths).not.toContain(keysMetaPath('user')) + } + }) +}) diff --git a/test/signals/identityArrayTracking.spec.ts b/test/signals/identityArrayTracking.spec.ts new file mode 100644 index 000000000..cc3842f09 --- /dev/null +++ b/test/signals/identityArrayTracking.spec.ts @@ -0,0 +1,1067 @@ +import { describe, it, expect } from 'vitest' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { createTrackingProxy } from '../../src/signals/trackingProxy' +import { diffAndUpdateSignals, reconcileState } from '../../src/signals/diff' +import { + findKeyField, + buildIdentityPath, + getKeyValue, +} from '../../src/signals/arrayKeys' + +// Helper: create a registry with signals pre-populated by running a selector through a tracking proxy +function setupRegistry(state: object, selectorFn: (s: any) => void) { + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + selectorFn(proxy) + return registry +} + +function deepFreeze(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj + Object.freeze(obj) + for (const val of Object.values(obj as Record)) { + if (val !== null && typeof val === 'object' && !Object.isFrozen(val)) { + deepFreeze(val) + } + } + return obj +} + +describe('findKeyField', () => { + it('detects "id" field', () => { + expect(findKeyField({ id: 1, name: 'Alice' })).toBe('id') + }) + + it('detects "key" field', () => { + expect(findKeyField({ key: 'abc', value: 42 })).toBe('key') + }) + + it('detects "_id" field (MongoDB-style)', () => { + expect(findKeyField({ _id: 'abc123', name: 'test' })).toBe('_id') + }) + + it('detects "__id" field', () => { + expect(findKeyField({ __id: 99, data: 'x' })).toBe('__id') + }) + + it('prefers "id" over "key"', () => { + expect(findKeyField({ id: 1, key: 'abc' })).toBe('id') + }) + + it('prefers "key" over "_id"', () => { + expect(findKeyField({ key: 'abc', _id: 'xyz' })).toBe('key') + }) + + it('accepts string key values', () => { + expect(findKeyField({ id: 'uuid-123' })).toBe('id') + }) + + it('accepts number key values', () => { + expect(findKeyField({ id: 42 })).toBe('id') + }) + + it('rejects object key values', () => { + expect(findKeyField({ id: { nested: true }, name: 'x' })).toBe(undefined) + }) + + it('rejects boolean key values', () => { + expect(findKeyField({ id: true })).toBe(undefined) + }) + + it('rejects null key values', () => { + expect(findKeyField({ id: null })).toBe(undefined) + }) + + it('rejects undefined key values', () => { + expect(findKeyField({ id: undefined })).toBe(undefined) + }) + + it('returns undefined for arrays', () => { + expect(findKeyField([1, 2, 3])).toBe(undefined) + }) + + it('returns undefined for null', () => { + expect(findKeyField(null)).toBe(undefined) + }) + + it('returns undefined for primitives', () => { + expect(findKeyField(42)).toBe(undefined) + expect(findKeyField('str')).toBe(undefined) + }) + + it('returns undefined for objects without ID fields', () => { + expect(findKeyField({ name: 'Alice', age: 30 })).toBe(undefined) + }) +}) + +describe('buildIdentityPath', () => { + it('builds path with numeric id', () => { + expect(buildIdentityPath('items', 'id', 42)).toBe('items.{id:42}') + }) + + it('builds path with string id', () => { + expect(buildIdentityPath('items', 'id', 'abc')).toBe('items.{id:abc}') + }) + + it('builds path with custom key field', () => { + expect(buildIdentityPath('data', '_id', 'mongo123')).toBe( + 'data.{_id:mongo123}', + ) + }) + + it('handles empty parent path', () => { + expect(buildIdentityPath('', 'id', 1)).toBe('{id:1}') + }) + + it('handles nested parent path', () => { + expect(buildIdentityPath('state.users.list', 'id', 5)).toBe( + 'state.users.list.{id:5}', + ) + }) +}) + +describe('getKeyValue', () => { + it('extracts string key', () => { + expect(getKeyValue({ id: 'abc' }, 'id')).toBe('abc') + }) + + it('extracts number key', () => { + expect(getKeyValue({ id: 42 }, 'id')).toBe(42) + }) + + it('returns undefined for missing field', () => { + expect(getKeyValue({ name: 'x' }, 'id')).toBe(undefined) + }) + + it('returns undefined for non-primitive key value', () => { + expect(getKeyValue({ id: { x: 1 } }, 'id')).toBe(undefined) + }) + + it('returns undefined for null input', () => { + expect(getKeyValue(null, 'id')).toBe(undefined) + }) + + it('returns undefined for array input', () => { + expect(getKeyValue([1, 2], 'id')).toBe(undefined) + }) + + it('returns undefined for primitive input', () => { + expect(getKeyValue(42, 'id')).toBe(undefined) + }) +}) + +describe('identity-based array tracking: proxy', () => { + it('uses identity path for array elements with id field', () => { + const state = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].name + s.items[1].name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + // Should NOT have index-based paths + expect(registry.has('items.0.name')).toBe(false) + expect(registry.has('items.1.name')).toBe(false) + }) + + it('uses identity path for "key" field', () => { + const state = deepFreeze({ + items: [ + { key: 'a', value: 1 }, + { key: 'b', value: 2 }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].value + s.items[1].value + }) + + expect(registry.has('items.{key:a}.value')).toBe(true) + expect(registry.has('items.{key:b}.value')).toBe(true) + }) + + it('uses identity path for "_id" field', () => { + const state = deepFreeze({ + docs: [{ _id: 'abc', title: 'Doc1' }], + }) + const registry = setupRegistry(state, (s) => { + s.docs[0].title + }) + + expect(registry.has('docs.{_id:abc}.title')).toBe(true) + }) + + it('falls back to index path for arrays without key fields', () => { + const state = deepFreeze({ + items: [ + { name: 'Alice', age: 30 }, + { name: 'Bob', age: 25 }, + ], + }) + const registry = setupRegistry(state, (s) => { + s.items[0].name + s.items[1].name + }) + + // No id/key field — should use index-based paths + expect(registry.has('items.0.name')).toBe(true) + expect(registry.has('items.1.name')).toBe(true) + }) + + it('uses a coarse array signal for primitive arrays', () => { + const state = deepFreeze({ tags: ['a', 'b', 'c'] }) + const registry = setupRegistry(state, (s) => { + s.tags[0] + s.tags[1] + }) + + // Primitive elements don't get per-index signals — one coarse + // signal on the array covers all element reads. + expect(registry.has('tags')).toBe(true) + expect(registry.has('tags.0')).toBe(false) + expect(registry.has('tags.1')).toBe(false) + }) + + it('creates ArrayMeta on first element access', () => { + const state = deepFreeze({ + items: [{ id: 1, name: 'Alice' }], + }) + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Before access — no meta + expect(registry.getArrayMeta('items')).toBeUndefined() + + // Access an element + ;(proxy as any).items[0].name + + // Now meta should exist + const meta = registry.getArrayMeta('items') + expect(meta).toBeDefined() + expect(meta!.keyField).toBe('id') + }) + + it('reuses ArrayMeta on subsequent accesses', () => { + const state = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const registry = createPathSignalRegistry(alienEngine) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + ;(proxy as any).items[0].name + const meta1 = registry.getArrayMeta('items') + + ;(proxy as any).items[1].name + const meta2 = registry.getArrayMeta('items') + + // Same meta object — not recreated + expect(meta1).toBe(meta2) + }) + + it('handles nested arrays with identity paths', () => { + const state = deepFreeze({ + data: { + users: [ + { id: 'u1', name: 'Alice' }, + { id: 'u2', name: 'Bob' }, + ], + }, + }) + const registry = setupRegistry(state, (s) => { + s.data.users[0].name + s.data.users[1].name + }) + + expect(registry.has('data.users.{id:u1}.name')).toBe(true) + expect(registry.has('data.users.{id:u2}.name')).toBe(true) + }) + + it('uses index path for array-of-arrays', () => { + const state = deepFreeze({ + matrix: [ + [1, 2], + [3, 4], + ], + }) + const registry = setupRegistry(state, (s) => { + s.matrix[0][0] + s.matrix[1][1] + }) + + // Inner arrays use index paths (matrix.0, matrix.1); their primitive + // elements share a coarse signal on each inner array. + expect(registry.has('matrix.0')).toBe(true) + expect(registry.has('matrix.1')).toBe(true) + expect(registry.has('matrix.0.0')).toBe(false) + expect(registry.has('matrix.1.1')).toBe(false) + }) +}) + +describe('identity-based array tracking: diff', () => { + describe('entity update (no reorder)', () => { + it('updates only changed entity fields', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 250 }, // changed + { id: 3, name: 'Carol', score: 300 }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].score + s.items[1].score + s.items[2].score + }) + + const sig1 = registry.getOrCreate('items.{id:1}.score', 100) + const sig2 = registry.getOrCreate('items.{id:2}.score', 200) + const sig3 = registry.getOrCreate('items.{id:3}.score', 300) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(sig1.get()).toBe(100) // unchanged + expect(sig2.get()).toBe(250) // updated + expect(sig3.get()).toBe(300) // unchanged + }) + + it('skips entity entirely when structurally shared', () => { + const sharedEntity = deepFreeze({ id: 1, name: 'Alice', score: 100 }) + const prev = deepFreeze({ + items: [sharedEntity, { id: 2, name: 'Bob', score: 200 }], + }) + // Immer-style: share entity reference, change another + const next = deepFreeze({ + items: [sharedEntity, { id: 2, name: 'Bob', score: 250 }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].score + }) + + const nameSig = registry.getOrCreate('items.{id:1}.name', 'Alice') + const score1Sig = registry.getOrCreate('items.{id:1}.score', 100) + const score2Sig = registry.getOrCreate('items.{id:2}.score', 200) + + const initialName = nameSig.get() + const initialScore1 = score1Sig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // Shared entity skipped entirely (prevItem === nextItem) + expect(nameSig.get()).toBe(initialName) + expect(score1Sig.get()).toBe(initialScore1) + // Other entity updated + expect(score2Sig.get()).toBe(250) + }) + }) + + describe('entity addition', () => { + it('handles new entity appended', () => { + const shared = deepFreeze([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]) + const prev = deepFreeze({ items: shared }) + const next = deepFreeze({ + items: [...shared, { id: 3, name: 'Carol' }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const keysSignal = registry.getOrCreate('items.@@keys', 2) + const initialVersion = keysSignal.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // @@keys should update for length change + expect(keysSignal.get()).not.toBe(initialVersion) + // Existing entities unchanged (structurally shared) + expect(registry.getOrCreate('items.{id:1}.name', 'Alice').get()).toBe( + 'Alice', + ) + }) + + it('handles new entity prepended', () => { + const prev = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Existing entities are tracked by identity, not index + const bobSig = registry.getOrCreate('items.{id:2}.name', 'Bob') + const carolSig = registry.getOrCreate('items.{id:3}.name', 'Carol') + + diffAndUpdateSignals(prev, next, '', registry) + + // Bob and Carol's signals are stable — same identity path regardless of index + expect(bobSig.get()).toBe('Bob') + expect(carolSig.get()).toBe('Carol') + }) + }) + + describe('entity removal', () => { + it('prunes signals for removed entity', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 3, name: 'Carol', score: 300 }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].name + s.items[1].score + s.items[2].name + s.items[2].score + }) + + expect(registry.has('items.{id:2}.name')).toBe(true) + expect(registry.has('items.{id:2}.score')).toBe(true) + expect(registry.hasPrefix('items.{id:2}')).toBe(true) + + diffAndUpdateSignals(prev, next, '', registry) + + // Entity 2 removed — all its signals pruned + expect(registry.has('items.{id:2}.name')).toBe(false) + expect(registry.has('items.{id:2}.score')).toBe(false) + expect(registry.hasPrefix('items.{id:2}')).toBe(false) + // Remaining entities intact + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:3}.name')).toBe(true) + }) + + it('prunes signals when removing from beginning (shift)', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + s.items[2].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Entity 1 removed + expect(registry.has('items.{id:1}.name')).toBe(false) + // Entities 2 and 3 still exist at their identity paths + expect(registry.has('items.{id:2}.name')).toBe(true) + expect(registry.has('items.{id:3}.name')).toBe(true) + }) + + it('handles removing all entities', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ items: [] as any[] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(false) + }) + }) + + describe('entity reorder', () => { + it('does not update signals when entities just swap positions', () => { + const entityA = deepFreeze({ id: 1, name: 'Alice', score: 100 }) + const entityB = deepFreeze({ id: 2, name: 'Bob', score: 200 }) + const prev = deepFreeze({ items: [entityA, entityB] }) + // Swap positions but share refs (Immer structural sharing) + const next = deepFreeze({ items: [entityB, entityA] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[0].score + s.items[1].name + s.items[1].score + }) + + const aliceNameSig = registry.getOrCreate('items.{id:1}.name', 'Alice') + const aliceScoreSig = registry.getOrCreate('items.{id:1}.score', 100) + const bobNameSig = registry.getOrCreate('items.{id:2}.name', 'Bob') + const bobScoreSig = registry.getOrCreate('items.{id:2}.score', 200) + + const initialAliceName = aliceNameSig.get() + const initialAliceScore = aliceScoreSig.get() + const initialBobName = bobNameSig.get() + const initialBobScore = bobScoreSig.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // With identity-based tracking, reorder with shared refs → zero updates + expect(aliceNameSig.get()).toBe(initialAliceName) + expect(aliceScoreSig.get()).toBe(initialAliceScore) + expect(bobNameSig.get()).toBe(initialBobName) + expect(bobScoreSig.get()).toBe(initialBobScore) + }) + + it('handles reorder with content change', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice', score: 100 }, + { id: 2, name: 'Bob', score: 200 }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 2, name: 'Bob', score: 250 }, // moved AND changed + { id: 1, name: 'Alice', score: 100 }, // just moved + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].score + s.items[1].score + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Alice unchanged (even though she moved) + expect(registry.getOrCreate('items.{id:1}.score', 100).get()).toBe(100) + // Bob changed + expect(registry.getOrCreate('items.{id:2}.score', 250).get()).toBe(250) + }) + + it('handles complete reversal', () => { + const e1 = deepFreeze({ id: 1, v: 'a' }) + const e2 = deepFreeze({ id: 2, v: 'b' }) + const e3 = deepFreeze({ id: 3, v: 'c' }) + const prev = deepFreeze({ items: [e1, e2, e3] }) + const next = deepFreeze({ items: [e3, e2, e1] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].v + s.items[1].v + s.items[2].v + }) + + const sig1 = registry.getOrCreate('items.{id:1}.v', 'a') + const sig2 = registry.getOrCreate('items.{id:2}.v', 'b') + const sig3 = registry.getOrCreate('items.{id:3}.v', 'c') + + const init1 = sig1.get() + const init2 = sig2.get() + const init3 = sig3.get() + + diffAndUpdateSignals(prev, next, '', registry) + + // All shared refs — zero updates + expect(sig1.get()).toBe(init1) + expect(sig2.get()).toBe(init2) + expect(sig3.get()).toBe(init3) + }) + }) + + describe('entityMap lifecycle', () => { + it('populates entityMap from prev on first diff', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Meta exists (created by proxy) but entityMap is empty + const meta = registry.getArrayMeta('items') + expect(meta).toBeDefined() + expect(meta!.entityMap.size).toBe(0) + + diffAndUpdateSignals(prev, next, '', registry) + + // After diff, entityMap is populated with next array's entities + expect(meta!.entityMap.size).toBe(2) + expect(meta!.entityMap.get(1)).toBe(next.items[0]) + expect(meta!.entityMap.get(2)).toBe(next.items[1]) + }) + + it('updates entityMap after each diff', () => { + const state1 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const state2 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + { id: 3, name: 'Carol' }, + ], + }) + const state3 = deepFreeze({ + items: [{ id: 1, name: 'Alice' }], + }) + + const registry = setupRegistry(state1, (s) => { + s.items[0].name + s.items[1].name + }) + + // First diff + diffAndUpdateSignals(state1, state2, '', registry) + const meta = registry.getArrayMeta('items')! + expect(meta.entityMap.size).toBe(3) + + // Second diff + diffAndUpdateSignals(state2, state3, '', registry) + expect(meta.entityMap.size).toBe(1) + expect(meta.entityMap.has(2)).toBe(false) + expect(meta.entityMap.has(3)).toBe(false) + }) + }) + + describe('edge cases', () => { + it('handles empty array → entities', () => { + const prev = deepFreeze({ items: [] as any[] }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items.length + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // No crash, @@keys updated + const keysSignal = registry.getOrCreate('items.@@keys', 2) + expect(typeof keysSignal.get()).toBe('number') + }) + + it('handles entities → empty array', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ items: [] as any[] }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // All entities pruned + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(false) + }) + + it('handles duplicate IDs gracefully', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Also Alice' }, // duplicate ID + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 1, name: 'Still Alice' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + // Should not throw — just works with last-wins semantics + expect(() => diffAndUpdateSignals(prev, next, '', registry)).not.toThrow() + }) + + it('handles entity with id=0', () => { + const prev = deepFreeze({ + items: [ + { id: 0, name: 'Zero' }, + { id: 1, name: 'One' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 0, name: 'Zero Updated' }, + { id: 1, name: 'One' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect( + registry.getOrCreate('items.{id:0}.name', 'Zero Updated').get(), + ).toBe('Zero Updated') + expect(registry.getOrCreate('items.{id:1}.name', 'One').get()).toBe('One') + }) + + it('handles entity with empty string id', () => { + const prev = deepFreeze({ + items: [{ id: '', name: 'Empty' }], + }) + const next = deepFreeze({ + items: [{ id: '', name: 'Updated' }], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.getOrCreate('items.{id:}.name', 'Updated').get()).toBe( + 'Updated', + ) + }) + + it('handles mixed keyed and non-keyed arrays in same state', () => { + const prev = deepFreeze({ + users: [{ id: 1, name: 'Alice' }], + tags: ['react', 'redux'], + }) + const next = deepFreeze({ + users: [{ id: 1, name: 'Alicia' }], + tags: ['react', 'signals'], + }) + + const registry = setupRegistry(prev, (s) => { + s.users[0].name + s.tags[0] + s.tags[1] + }) + + diffAndUpdateSignals(prev, next, '', registry) + + // Users: identity-based + expect(registry.getOrCreate('users.{id:1}.name', 'Alicia').get()).toBe( + 'Alicia', + ) + // Tags: index-based (primitive array) + expect(registry.getOrCreate('tags.1', 'signals').get()).toBe('signals') + }) + + it('handles deeply nested entity arrays', () => { + const prev = deepFreeze({ + data: { + nested: { + items: [ + { id: 'a', val: 1 }, + { id: 'b', val: 2 }, + ], + }, + }, + }) + const next = deepFreeze({ + data: { + nested: { + items: [ + { id: 'a', val: 1 }, + { id: 'b', val: 99 }, + ], + }, + }, + }) + + const registry = setupRegistry(prev, (s) => { + s.data.nested.items[0].val + s.data.nested.items[1].val + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect( + registry.getOrCreate('data.nested.items.{id:a}.val', 1).get(), + ).toBe(1) + expect( + registry.getOrCreate('data.nested.items.{id:b}.val', 99).get(), + ).toBe(99) + }) + + it('handles entities with nested objects', () => { + const prev = deepFreeze({ + items: [ + { id: 1, profile: { name: 'Alice', age: 30 } }, + { id: 2, profile: { name: 'Bob', age: 25 } }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, profile: { name: 'Alice', age: 31 } }, // age changed + { id: 2, profile: { name: 'Bob', age: 25 } }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].profile.age + s.items[1].profile.age + }) + + diffAndUpdateSignals(prev, next, '', registry) + + expect(registry.getOrCreate('items.{id:1}.profile.age', 31).get()).toBe( + 31, + ) + expect(registry.getOrCreate('items.{id:2}.profile.age', 25).get()).toBe( + 25, + ) + }) + }) + + describe('reactive propagation with identity tracking', () => { + it('triggers computed only for changed entity', () => { + const prev = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const next = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bobby' }, + ], + }) + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const sig1 = registry.getOrCreate('items.{id:1}.name', 'Alice') + const sig2 = registry.getOrCreate('items.{id:2}.name', 'Bob') + + let computed1Runs = 0 + let computed2Runs = 0 + + const c1 = alienEngine.computed(() => { + computed1Runs++ + return sig1.get() + }) + const c2 = alienEngine.computed(() => { + computed2Runs++ + return sig2.get() + }) + + // Initial read + c1.get() + c2.get() + const initialRuns1 = computed1Runs + const initialRuns2 = computed2Runs + + reconcileState(prev, next, registry, alienEngine) + + c1.get() + c2.get() + + // Only entity 2 changed — only computed2 should re-run + expect(computed1Runs).toBe(initialRuns1) // no re-run + expect(computed2Runs).toBe(initialRuns2 + 1) // re-ran + expect(c2.get()).toBe('Bobby') + }) + + it('does not trigger computed for reorder-only change', () => { + const e1 = deepFreeze({ id: 1, name: 'Alice' }) + const e2 = deepFreeze({ id: 2, name: 'Bob' }) + const prev = deepFreeze({ items: [e1, e2] }) + const next = deepFreeze({ items: [e2, e1] }) // swapped + + const registry = setupRegistry(prev, (s) => { + s.items[0].name + s.items[1].name + }) + + const sig1 = registry.getOrCreate('items.{id:1}.name', 'Alice') + const sig2 = registry.getOrCreate('items.{id:2}.name', 'Bob') + + let runs = 0 + const derived = alienEngine.computed(() => { + runs++ + return `${sig1.get()}-${sig2.get()}` + }) + + derived.get() + const initialRuns = runs + + reconcileState(prev, next, registry, alienEngine) + + derived.get() + // No content changed, just reorder with shared refs → zero signal updates → no re-run + expect(runs).toBe(initialRuns) + }) + }) + + describe('multi-dispatch sequences', () => { + it('handles add → update → remove sequence', () => { + const state0 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + }) + const state1 = deepFreeze({ + items: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const state2 = deepFreeze({ + items: [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + const state3 = deepFreeze({ + items: [ + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + }) + + const registry = setupRegistry(state0, (s) => { + s.items[0].name // tracks {id:1}.name + s.items[1].name // tracks {id:2}.name + }) + + expect(registry.has('items.{id:1}.name')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(true) + + // Dispatch 1: add entity 3 + diffAndUpdateSignals(state0, state1, '', registry) + // @@keys updated but entity 3 not tracked (never accessed via proxy) + + // Dispatch 2: update entity 1's name + diffAndUpdateSignals(state1, state2, '', registry) + expect(registry.getOrCreate('items.{id:1}.name', 'Alicia').get()).toBe( + 'Alicia', + ) + + // Dispatch 3: remove entity 1 + diffAndUpdateSignals(state2, state3, '', registry) + expect(registry.has('items.{id:1}.name')).toBe(false) + expect(registry.has('items.{id:2}.name')).toBe(true) + }) + + it('handles repeated reorders', () => { + const e1 = deepFreeze({ id: 1, v: 'a' }) + const e2 = deepFreeze({ id: 2, v: 'b' }) + const e3 = deepFreeze({ id: 3, v: 'c' }) + + const s0 = deepFreeze({ items: [e1, e2, e3] }) + const s1 = deepFreeze({ items: [e3, e1, e2] }) + const s2 = deepFreeze({ items: [e2, e3, e1] }) + const s3 = deepFreeze({ items: [e1, e2, e3] }) // back to original + + const registry = setupRegistry(s0, (s) => { + s.items[0].v + s.items[1].v + s.items[2].v + }) + + const sig1 = registry.getOrCreate('items.{id:1}.v', 'a') + const sig2 = registry.getOrCreate('items.{id:2}.v', 'b') + const sig3 = registry.getOrCreate('items.{id:3}.v', 'c') + + const init1 = sig1.get() + const init2 = sig2.get() + const init3 = sig3.get() + + diffAndUpdateSignals(s0, s1, '', registry) + diffAndUpdateSignals(s1, s2, '', registry) + diffAndUpdateSignals(s2, s3, '', registry) + + // After multiple reorders with shared refs, signals unchanged + expect(sig1.get()).toBe(init1) + expect(sig2.get()).toBe(init2) + expect(sig3.get()).toBe(init3) + }) + }) +}) diff --git a/test/signals/identityComparison.spec.tsx b/test/signals/identityComparison.spec.tsx new file mode 100644 index 000000000..710d82115 --- /dev/null +++ b/test/signals/identityComparison.spec.tsx @@ -0,0 +1,1389 @@ +/* eslint-disable react/prop-types */ + +import * as rtl from '@testing-library/react' +import React, { useLayoutEffect } from 'react' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +// ============================================================================ +// Test: Object Identity Comparison in Selectors +// +// Investigates whether selectors relying on object identity (===, Object.is) +// are correctly tracked by our path-signal tracking proxy architecture. +// +// ROOT CAUSE OF FAILURES: +// When the tracking proxy's `get` trap encounters an object value, it calls +// `registry.ensurePrefix(pathKey)` — which registers the path in the prefix +// index but does NOT create a signal or establish a reactive dependency. +// Only primitive leaf values call `registry.getOrCreate(pathKey, value).get()` +// which creates a signal and reads it (establishing dep via alien-signals). +// +// When a selector does `state.a === state.b` (both objects), the get trap +// fires for both reads, but since both are objects, only ensurePrefix is +// called. The selector's computed ends up with ZERO signal dependencies. +// It never re-runs, regardless of state changes. +// +// ADDITIONAL ISSUE: Array.includes() override +// The `includes` override calls `target[m](...args)` on the raw frozen array, +// but the search argument is a proxy (e.g., `state.selectedItem`). Since +// `proxy !== rawObject`, `includes` always returns false for object elements. +// +// CORRECTLY WORKING: +// - Proxy cache ensures same underlying object → same proxy instance, so +// `state.draft === state.published` returns the correct boolean initially +// - Selectors that access primitive properties of objects work fine +// - `useSignalSelector` terminal proxy detection works when selector returns +// an object (proxy), but doesn't help when selector returns a boolean +// ============================================================================ + +// ============================================================================ +// Store Setup +// ============================================================================ + +interface Item { + id: number + name: string +} + +interface NestedObj { + label: string +} + +interface IdentityTestState { + items: Item[] + selectedItem: Item | null + draft: Record | null + published: Record | null + current: Record + saved: Record + listA: Item[] + setB: number[] // IDs in set B + // Additional fields for edge case tests + objA: NestedObj + objB: NestedObj + deep: { nested: { target: NestedObj } } + maybeNull: NestedObj | null + siblingPrimitive: number +} + +const initialItems: Item[] = [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, + { id: 3, name: 'Gamma' }, +] + +const sharedObj = { title: 'shared', version: 1 } +const sharedNested: NestedObj = { label: 'shared-nested' } + +const identitySlice = createSlice({ + name: 'identity', + initialState: { + items: initialItems, + selectedItem: initialItems[0], // same ref as items[0] + draft: sharedObj, + published: sharedObj, // same ref as draft + current: { x: 1 } as Record, + saved: { x: 1 } as Record, // different ref, same value + listA: initialItems, + setB: [1, 3], // IDs of items in set B + objA: sharedNested, + objB: sharedNested, // same ref as objA + deep: { nested: { target: sharedNested } }, + maybeNull: sharedNested as NestedObj | null, + siblingPrimitive: 0, + } as IdentityTestState, + reducers: { + selectItem(state, action: PayloadAction) { + state.selectedItem = state.items[action.payload] ?? null + }, + clearSelection(state) { + state.selectedItem = null + }, + publishDraft(state) { + // Make draft and published point to the same new object + const newObj = { title: 'updated', version: 2 } + state.draft = newObj + state.published = newObj + }, + divergeDraftFromPublished(state) { + // Make draft differ from published + state.draft = { title: 'draft-v2', version: 3 } + // published stays at its current value + }, + syncCurrentToSaved(state) { + // Make current and saved point to the same ref + // In Immer, both get new refs but with same content + state.saved = { ...state.current } + }, + mutateCurrent(state) { + state.current = { x: 999 } + }, + addToSetB(state, action: PayloadAction) { + state.setB.push(action.payload) + }, + removeFromSetB(state, action: PayloadAction) { + state.setB = state.setB.filter((id) => id !== action.payload) + }, + updateItemName( + state, + action: PayloadAction<{ index: number; name: string }>, + ) { + state.items[action.payload.index].name = action.payload.name + }, + replaceItems(state, action: PayloadAction) { + state.items = action.payload + // selectedItem is now stale (points to old array's item) + }, + // Edge case reducers + setObjA(state, action: PayloadAction) { + state.objA = action.payload + }, + setObjB(state, action: PayloadAction) { + state.objB = action.payload + }, + setDeepTarget(state, action: PayloadAction) { + state.deep.nested.target = action.payload + }, + setMaybeNull(state, action: PayloadAction) { + state.maybeNull = action.payload + }, + bumpSiblingPrimitive(state) { + state.siblingPrimitive += 1 + }, + }, +}) + +function createIdentityStore() { + return configureStore({ + reducer: { + identity: identitySlice.reducer, + }, + }) +} + +type IdentityStore = ReturnType +type RootState = ReturnType + +// ============================================================================ +// Tests +// ============================================================================ + +describe('object identity comparison in selectors', () => { + let store: IdentityStore + + beforeEach(() => { + store = createIdentityStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + // ========================================================================== + // Pattern 1: selectedItem === items[i] + // + // A selector reads state.selectedItem and state.items[i] and compares them. + // The proxy's get trap fires for both reads. The question is whether the + // dependency tracking is sufficient to re-run when selectedItem changes + // to point to a different object. + // ========================================================================== + + describe('Pattern 1: selectedItem === items[i]', () => { + it('detects when selectedItem is the same ref as items[0]', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially selectedItem is items[0] (same ref in initialState) + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: selector reads two objects (selectedItem, items[0]) but + // never accesses a primitive property on either. The tracking proxy only + // calls ensurePrefix for objects — no signal dependency is created. + // The computed has zero reactive sources and never re-runs. + it('updates when selectedItem changes to a different item', () => { + let selectorCalls = 0 + + const Comp = () => { + const isSelected = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.selectedItem === state.identity.items[0] + }) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Change selection to items[1] — selectedItem now differs from items[0] + rtl.act(() => { + store.dispatch(identitySlice.actions.selectItem(1)) + }) + + // The selector MUST re-run and return false + expect(getByTestId('result').textContent).toBe('false') + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + + // KNOWN FAILURE: same root cause — selectedItem changes from an object + // proxy to null, but no signal was tracking the selectedItem path. + it('updates when selectedItem becomes null', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch(identitySlice.actions.clearSelection()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('updates when items array is replaced (selected item becomes stale)', () => { + const Comp = () => { + const isSelected = useSignalSelector( + (state: RootState) => + state.identity.selectedItem === state.identity.items[0], + ) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Replace items array entirely — selectedItem still points to old item + rtl.act(() => { + store.dispatch( + identitySlice.actions.replaceItems([ + { id: 10, name: 'New Alpha' }, + { id: 20, name: 'New Beta' }, + ]), + ) + }) + + // selectedItem is the old {id:1, name:'Alpha'}, items[0] is now {id:10, name:'New Alpha'} + // These are different refs → should be false + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 2: draft === published (cross-slice identity) + // + // Two state branches might point to the same object. Selector checks + // if they are identical. + // ========================================================================== + + describe('Pattern 2: draft === published', () => { + it('detects when draft and published are the same ref', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially draft and published are the same object ref + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: draft and published are both objects. The selector + // compares their identity but never reads any primitive property. + // Zero signal deps → computed never re-runs. + it('updates when draft diverges from published', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('stays true when both draft and published are updated to same new ref', () => { + const Comp = () => { + const isSynced = useSignalSelector( + (state: RootState) => + state.identity.draft === state.identity.published, + ) + return
{String(isSynced)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Both draft and published get set to the same new object + rtl.act(() => { + store.dispatch(identitySlice.actions.publishDraft()) + }) + + // After Immer, these will be different refs (Immer creates new objects) + // but the selector should still reflect the actual state + const state = store.getState() + const expectedResult = state.identity.draft === state.identity.published + expect(getByTestId('result').textContent).toBe(String(expectedResult)) + }) + }) + + // ========================================================================== + // Pattern 3: current !== saved (boolean from identity comparison) + // + // Selector returns a boolean based on whether two objects are the same ref. + // This is the "has unsaved changes" pattern. + // ========================================================================== + + describe('Pattern 3: current !== saved (unsaved changes detection)', () => { + it('returns true when current and saved are different refs', () => { + const Comp = () => { + const hasChanges = useSignalSelector( + (state: RootState) => state.identity.current !== state.identity.saved, + ) + return
{String(hasChanges)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially current and saved are different objects (even though same values) + expect(getByTestId('result').textContent).toBe('true') + }) + + // KNOWN FAILURE: selector reads current and saved (objects), compares + // identity, returns boolean. No signal deps created for object-only reads. + // The selector never re-runs even when current is replaced. + it('updates when current is mutated', () => { + let selectorCalls = 0 + + const Comp = () => { + const hasChanges = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.current !== state.identity.saved + }) + return
{String(hasChanges)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Mutate current — it was already !== saved, stays that way + // But selector should still re-run because current's ref changed + rtl.act(() => { + store.dispatch(identitySlice.actions.mutateCurrent()) + }) + + // Result is still true, but selector should have re-run due to + // the version counter on identity.current changing + expect(getByTestId('result').textContent).toBe('true') + // We expect the selector to have been re-evaluated + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + }) + + // ========================================================================== + // Pattern 4: Reading object ref without accessing properties + // + // The selector reads an object but only uses it for identity comparison, + // never accessing any of its properties. Does the proxy still track this? + // ========================================================================== + + describe('Pattern 4: object ref read without property access', () => { + it('tracks object ref changes via proxy version counter', () => { + // This selector reads state.identity.current (an object) but doesn't + // access any of its properties. It just checks if the ref exists. + const Comp = () => { + const hasCurrent = useSignalSelector( + (state: RootState) => state.identity.current != null, + ) + return
{String(hasCurrent)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + }) + + it('selector re-runs when object ref changes even without property access', () => { + let selectorCalls = 0 + + // This is the tricky case: the selector reads two object refs and + // compares them, but never accesses any property inside either object. + // The proxy's get trap fires to return the child proxy, but + // ensurePrefix is called (NOT getOrCreate). No signal dependency is + // established for intermediate objects. + // + // HOWEVER: useSignalSelector checks if the result is a proxy and + // explicitly reads its signal. But here the result is a boolean, + // not a proxy. So we rely on intermediate version counters. + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + selectorCalls++ + // Read two object refs — get trap fires for each + const current = state.identity.current + const saved = state.identity.saved + // Compare identity — no property access on either + return current === saved + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially different refs + expect(getByTestId('result').textContent).toBe('false') + const callsAfterMount = selectorCalls + + // Mutate current to a new object — still !== saved + rtl.act(() => { + store.dispatch(identitySlice.actions.mutateCurrent()) + }) + + // The result is still false (different refs), so even if selector + // re-runs, the component may not re-render. + // The key question: does the selector re-run at all? + expect(getByTestId('result').textContent).toBe('false') + }) + + it('detects identity change when result flips from false to true', () => { + // Start with current !== saved, then sync them. + // This is the critical test: if no deps are tracked for the object + // reads, the selector won't re-run and the UI stays stale. + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + const current = state.identity.current + const saved = state.identity.saved + return current === saved + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Sync saved to match current + rtl.act(() => { + store.dispatch(identitySlice.actions.syncCurrentToSaved()) + }) + + // After Immer, saved gets a new object with same content as current. + // But Immer creates a new ref for saved, so current !== saved still. + // Let's check what the store actually has: + const state = store.getState() + const expected = state.identity.current === state.identity.saved + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 5: Array.includes / Set.has with object identity + // + // Selectors that use methods relying on identity comparison internally. + // ========================================================================== + + describe('Pattern 5: identity-based collection methods', () => { + it('tracks when using manual identity check across arrays', () => { + // Selector: find items from listA whose id is in setB + const Comp = () => { + const matchingItems = useSignalSelector((state: RootState) => { + const setBIds = new Set(state.identity.setB) + return state.identity.listA.filter((item) => setBIds.has(item.id)) + }) + return ( +
+ {matchingItems.map((i) => i.name).join(',')} +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + // setB has ids [1, 3], so Alpha and Gamma match + expect(getByTestId('result').textContent).toBe('Alpha,Gamma') + + // Add id 2 to setB + rtl.act(() => { + store.dispatch(identitySlice.actions.addToSetB(2)) + }) + + expect(getByTestId('result').textContent).toBe('Alpha,Beta,Gamma') + }) + + it('updates when setB changes to exclude an item', () => { + const Comp = () => { + const matchingItems = useSignalSelector((state: RootState) => { + const setBIds = new Set(state.identity.setB) + return state.identity.listA.filter((item) => setBIds.has(item.id)) + }) + return ( +
+ {matchingItems.map((i) => i.name).join(',')} +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('Alpha,Gamma') + + // Remove id 1 from setB + rtl.act(() => { + store.dispatch(identitySlice.actions.removeFromSetB(1)) + }) + + expect(getByTestId('result').textContent).toBe('Gamma') + }) + + // KNOWN FAILURE (different root cause): Array.includes override calls + // `target[m](...args)` on the RAW frozen array, but the search argument + // `state.identity.selectedItem` is a PROXY. Since proxy !== rawObject, + // includes always returns false for object elements. + // This is a bug in arrayMethodOverrides.ts — the search arg should be + // unwrapped to its raw target before passing to the native method. + it('selector using Array.includes with object identity', () => { + // This test creates a scenario where Array.includes is used with + // object references. Array.includes uses ===. + const Comp = () => { + const isFirstItemSelected = useSignalSelector((state: RootState) => { + // This is subtle: state.identity.items is a proxy, and + // state.identity.selectedItem is also a proxy. + // Array.includes on a proxy array checks if the proxy of + // selectedItem is in the proxy array. + return state.identity.items.includes( + state.identity.selectedItem as Item, + ) + }) + return
{String(isFirstItemSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // selectedItem is items[0], and items includes it + expect(getByTestId('result').textContent).toBe('true') + + // Clear selection — null is not in items + rtl.act(() => { + store.dispatch(identitySlice.actions.clearSelection()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 6: Selective re-execution verification + // + // The key concern: does the selector re-run when it SHOULD but no + // property-level signals changed? This tests whether our version + // counters on intermediate objects are sufficient. + // ========================================================================== + + describe('Pattern 6: selective re-execution with identity selectors', () => { + it('identity selector re-runs when unrelated slice changes do NOT trigger it', () => { + let identitySelectorCalls = 0 + let unrelatedSelectorCalls = 0 + + const IdentityComp = () => { + const isSame = useSignalSelector((state: RootState) => { + identitySelectorCalls++ + return state.identity.draft === state.identity.published + }) + return
{String(isSame)}
+ } + + // A second component reading something entirely different + const UnrelatedComp = () => { + const name = useSignalSelector((state: RootState) => { + unrelatedSelectorCalls++ + return state.identity.items[0].name + }) + return
{name}
+ } + + rtl.render( + + + + , + ) + + const identityCallsAfterMount = identitySelectorCalls + const unrelatedCallsAfterMount = unrelatedSelectorCalls + + // Update items[0].name — only the unrelated selector should re-run + rtl.act(() => { + store.dispatch( + identitySlice.actions.updateItemName({ index: 0, name: 'AlphaV2' }), + ) + }) + + expect(unrelatedSelectorCalls).toBeGreaterThan(unrelatedCallsAfterMount) + // Identity selector should NOT re-run — draft/published didn't change + // (The exact behavior depends on whether identity.draft and identity.published + // share parent object version signals that fire for ANY identity.* change) + // This is informational — we record whether it ran or not + const identityRan = identitySelectorCalls > identityCallsAfterMount + + // Whether or not it re-ran, the result should be correct + expect( + document.querySelector('[data-testid="identity"]')!.textContent, + ).toBe('true') + + // Log for analysis + console.log( + `Identity selector re-ran on unrelated change: ${identityRan}`, + ) + }) + + // KNOWN FAILURE: same root cause as all identity comparison failures. + // The selector reads draft and published (objects), no signal deps. + it('identity selector DOES re-run when relevant ref changes', () => { + let selectorCalls = 0 + + const Comp = () => { + const isSame = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.draft === state.identity.published + }) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const callsAfterMount = selectorCalls + + // Diverge draft from published — selector MUST re-run and return false + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 7: Proxy identity consistency + // + // Verify that when two state paths point to the same underlying object, + // the proxy cache returns the same proxy, making === work correctly + // THROUGH the proxy layer. + // ========================================================================== + + describe('Pattern 7: proxy identity preservation', () => { + it('same underlying object produces same proxy via cache', () => { + // This is fundamental: if state.draft and state.published point to + // the same object, do their proxies compare as === ? + let proxyDraft: unknown + let proxyPublished: unknown + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + proxyDraft = state.identity.draft + proxyPublished = state.identity.published + return state.identity.draft === state.identity.published + }) + return
{String(result)}
+ } + + rtl.render( + + + , + ) + + // Verify the proxies are actually the same object + expect(proxyDraft).toBe(proxyPublished) + }) + + it('different underlying objects produce different proxies', () => { + let proxyCurrent: unknown + let proxySaved: unknown + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + proxyCurrent = state.identity.current + proxySaved = state.identity.saved + return state.identity.current === state.identity.saved + }) + return
{String(result)}
+ } + + rtl.render( + + + , + ) + + // current and saved are different objects with same values + expect(proxyCurrent).not.toBe(proxySaved) + }) + }) + + // ========================================================================== + // Pattern 8: Immer structural sharing edge case + // + // When Immer updates one field but leaves another untouched, + // the untouched field keeps its reference. Test that identity + // comparisons reflect this correctly. + // ========================================================================== + + describe('Pattern 8: Immer structural sharing with identity', () => { + it('reflects Immer structural sharing in identity comparisons', () => { + // After dispatching divergeDraftFromPublished, draft changes but + // published keeps its old ref. selectedItem should still be items[0]. + const results: boolean[] = [] + + const Comp = () => { + const selectedIsFirst = useSignalSelector((state: RootState) => { + return state.identity.selectedItem === state.identity.items[0] + }) + results.push(selectedIsFirst) + return
{String(selectedIsFirst)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Dispatch something that doesn't affect items or selectedItem + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + // selectedItem and items[0] should still be the same ref + // (Immer structural sharing preserves them) + const state = store.getState() + expect(state.identity.selectedItem).toBe(state.identity.items[0]) + + // The UI should still show true + expect(getByTestId('result').textContent).toBe('true') + }) + }) + + // ========================================================================== + // Pattern 9: Workarounds — identity comparison WITH property access + // + // These tests show that adding a primitive property access alongside + // the identity comparison makes the selector correctly reactive. + // This is the natural workaround for real-world selectors. + // ========================================================================== + + describe('Pattern 9: workaround — identity comparison with property access', () => { + it('identity selector works when also reading a primitive property', () => { + // This is the realistic pattern: compare identity BUT ALSO read + // a property (like id or name) from the objects. + const Comp = () => { + const isSelected = useSignalSelector((state: RootState) => { + // Reading .id creates a signal dependency on selectedItem.id + // and items.{id:1}.id — these signals fire when the refs change + const selId = state.identity.selectedItem?.id + const firstId = state.identity.items[0]?.id + return selId === firstId + }) + return
{String(isSelected)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Change selection to items[1] + rtl.act(() => { + store.dispatch(identitySlice.actions.selectItem(1)) + }) + + // Now selectedItem.id is 2, items[0].id is still 1 → false + expect(getByTestId('result').textContent).toBe('false') + }) + + it('identity selector works when reading any leaf from compared objects', () => { + // Comparing draft and published identity, but also reading .title + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + // Read a primitive from each to establish signal deps + const draftTitle = (state.identity.draft as Record) + ?.title + const pubTitle = (state.identity.published as Record) + ?.title + return draftTitle === pubTitle + }) + return
{String(result)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + // Diverge draft + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + }) + + // ========================================================================== + // Pattern 10: indexOf/lastIndexOf with proxy arguments + // + // Same root cause as includes: the override calls the raw array method + // with a proxy argument, which can't match raw objects. + // ========================================================================== + + describe('Pattern 10: indexOf/lastIndexOf with object args', () => { + it('indexOf returns correct index for proxy-wrapped object', () => { + const Comp = () => { + const idx = useSignalSelector((state: RootState) => { + return state.identity.items.indexOf( + state.identity.selectedItem as Item, + ) + }) + return
{String(idx)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // selectedItem is items[0], so indexOf should return 0 + expect(getByTestId('result').textContent).toBe('0') + }) + }) + + // ========================================================================== + // Pattern 11: Null/undefined identity transitions + // + // Object goes from non-null to null and back. Tests that the transition + // between object and null is tracked (type changes at a path). + // ========================================================================== + + describe('Pattern 11: null identity transitions', () => { + it('tracks object → null transition', () => { + const Comp = () => { + const isNull = useSignalSelector( + (state: RootState) => state.identity.maybeNull === null, + ) + return
{String(isNull)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially maybeNull is sharedNested (not null) + expect(getByTestId('result').textContent).toBe('false') + + rtl.act(() => { + store.dispatch(identitySlice.actions.setMaybeNull(null)) + }) + + expect(getByTestId('result').textContent).toBe('true') + }) + + it('tracks null → object transition', () => { + // Start by setting to null + store.dispatch(identitySlice.actions.setMaybeNull(null)) + + const Comp = () => { + const isNull = useSignalSelector( + (state: RootState) => state.identity.maybeNull === null, + ) + return
{String(isNull)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + + rtl.act(() => { + store.dispatch( + identitySlice.actions.setMaybeNull({ label: 'restored' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks object → null → different object', () => { + let selectorCalls = 0 + + const Comp = () => { + const label = useSignalSelector((state: RootState) => { + selectorCalls++ + const obj = state.identity.maybeNull + return obj === null ? 'none' : 'exists' + }) + return
{label}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('exists') + + rtl.act(() => { + store.dispatch(identitySlice.actions.setMaybeNull(null)) + }) + expect(getByTestId('result').textContent).toBe('none') + + rtl.act(() => { + store.dispatch(identitySlice.actions.setMaybeNull({ label: 'new-obj' })) + }) + expect(getByTestId('result').textContent).toBe('exists') + }) + }) + + // ========================================================================== + // Pattern 12: Nested leaf object (deep traversal, identity at leaf) + // + // state.deep.nested.target used for identity. Three levels of intermediate + // traversal, leaf object at the end. + // ========================================================================== + + describe('Pattern 12: nested leaf object identity', () => { + it('tracks identity change on deeply nested object', () => { + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.deep.nested.target === state.identity.objA, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially deep.nested.target and objA are both sharedNested + expect(getByTestId('result').textContent).toBe('true') + + // Change deep.nested.target to a different object + rtl.act(() => { + store.dispatch( + identitySlice.actions.setDeepTarget({ label: 'different' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks when deeply nested object becomes same ref as sibling', () => { + // First diverge them + store.dispatch(identitySlice.actions.setObjA({ label: 'diverged' })) + + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => + state.identity.deep.nested.target === state.identity.objA, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Set deep.nested.target to a new object with same content as objA + // (Immer will make them different refs) + rtl.act(() => { + store.dispatch( + identitySlice.actions.setDeepTarget({ label: 'diverged' }), + ) + }) + + // After Immer, these are different refs even with same content + const state = store.getState() + const expected = state.identity.deep.nested.target === state.identity.objA + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 13: Multiple leaf objects from same parent + // + // state.objA === state.objB where both are siblings under identity. + // Tests that leaf detection works when both leaves share a parent. + // ========================================================================== + + describe('Pattern 13: sibling leaf objects', () => { + it('tracks identity between sibling objects', () => { + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially both are sharedNested + expect(getByTestId('result').textContent).toBe('true') + + // Diverge objA + rtl.act(() => { + store.dispatch(identitySlice.actions.setObjA({ label: 'changed' })) + }) + + expect(getByTestId('result').textContent).toBe('false') + }) + + it('tracks when siblings converge to same ref', () => { + // Diverge first + store.dispatch(identitySlice.actions.setObjA({ label: 'different' })) + + const Comp = () => { + const isSame = useSignalSelector( + (state: RootState) => state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('false') + + // Change objB to match objA's content (Immer creates new refs) + rtl.act(() => { + store.dispatch(identitySlice.actions.setObjB({ label: 'different' })) + }) + + const state = store.getState() + const expected = state.identity.objA === state.identity.objB + expect(getByTestId('result').textContent).toBe(String(expected)) + }) + }) + + // ========================================================================== + // Pattern 14: Parent version signal false-positive suppression + // + // Sibling primitive changes → parent version signal fires → selector + // re-runs → returns same result → equality check suppresses re-render. + // Verifies the "benign false positive" trade-off of parent version signals. + // ========================================================================== + + describe('Pattern 14: false-positive suppression via equality check', () => { + it('does not re-render when sibling changes but selector result is stable', () => { + let renderCount = 0 + + const Comp = () => { + renderCount++ + const isSame = useSignalSelector( + (state: RootState) => state.identity.objA === state.identity.objB, + ) + return
{String(isSame)}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true') + const rendersAfterMount = renderCount + + // Change siblingPrimitive — shares the identity parent with objA/objB. + // This fires the identity parent version signal. The selector re-runs + // but objA === objB is still true. Equality check suppresses re-render. + rtl.act(() => { + store.dispatch(identitySlice.actions.bumpSiblingPrimitive()) + }) + + // Result should still be true, and render count should not increase + expect(getByTestId('result').textContent).toBe('true') + expect(renderCount).toBe(rendersAfterMount) + }) + }) + + // ========================================================================== + // Pattern 15: Conditional property access based on identity + // + // Selector first checks identity, then conditionally reads properties. + // On first run it may only read objects (leaf detection needed). + // On re-run after state change, it reads different paths. + // ========================================================================== + + describe('Pattern 15: conditional property access after identity check', () => { + it('handles selector that conditionally reads properties', () => { + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + if (state.identity.objA === state.identity.objB) { + // Same ref — just return the label + return state.identity.objA.label + } + // Different refs — return comparison string + return `${state.identity.objA.label} vs ${state.identity.objB.label}` + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Initially same ref → returns label + expect(getByTestId('result').textContent).toBe('shared-nested') + + // Diverge objA — different refs now + rtl.act(() => { + store.dispatch(identitySlice.actions.setObjA({ label: 'alpha' })) + }) + + expect(getByTestId('result').textContent).toBe('alpha vs shared-nested') + }) + + it('handles short-circuit: reads only one side when first is null', () => { + // Set maybeNull to null first + store.dispatch(identitySlice.actions.setMaybeNull(null)) + + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + // Short circuit: if maybeNull is null, don't read objA + if (state.identity.maybeNull === null) { + return 'no-object' + } + return state.identity.maybeNull === state.identity.objA + ? 'same' + : 'different' + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('no-object') + + // Restore maybeNull — now it's a different ref from objA + rtl.act(() => { + store.dispatch( + identitySlice.actions.setMaybeNull({ label: 'restored' }), + ) + }) + + expect(getByTestId('result').textContent).toBe('different') + }) + }) + + // ========================================================================== + // Pattern 16: Returning a leaf object (terminal proxy check) + // + // Selector returns an object directly (not a boolean from comparison). + // The terminal proxy check in useSignalSelector should handle this. + // ========================================================================== + + describe('Pattern 16: selector returning leaf object', () => { + it('re-runs when returned object ref changes', () => { + let selectorCalls = 0 + + const Comp = () => { + const obj = useSignalSelector((state: RootState) => { + selectorCalls++ + return state.identity.objA + }) + return
{(obj as NestedObj).label}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('shared-nested') + const callsAfterMount = selectorCalls + + rtl.act(() => { + store.dispatch(identitySlice.actions.setObjA({ label: 'updated' })) + }) + + expect(getByTestId('result').textContent).toBe('updated') + expect(selectorCalls).toBeGreaterThan(callsAfterMount) + }) + }) + + // ========================================================================== + // Pattern 17: Mixed identity + primitive in same selector + // + // Selector reads both object identity and primitive properties. + // Tests that leaf object detection and parent version signals + // work together without interference. + // ========================================================================== + + describe('Pattern 17: mixed identity and primitive reads', () => { + it('tracks both identity and primitive changes', () => { + const Comp = () => { + const result = useSignalSelector((state: RootState) => { + const isSame = state.identity.draft === state.identity.published + const count = state.identity.siblingPrimitive + return `${isSame}-${count}` + }) + return
{result}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('result').textContent).toBe('true-0') + + // Change primitive + rtl.act(() => { + store.dispatch(identitySlice.actions.bumpSiblingPrimitive()) + }) + expect(getByTestId('result').textContent).toBe('true-1') + + // Change identity + rtl.act(() => { + store.dispatch(identitySlice.actions.divergeDraftFromPublished()) + }) + expect(getByTestId('result').textContent).toBe('false-1') + }) + }) +}) diff --git a/test/signals/interop.spec.tsx b/test/signals/interop.spec.tsx new file mode 100644 index 000000000..f8690cf11 --- /dev/null +++ b/test/signals/interop.spec.tsx @@ -0,0 +1,510 @@ +/** + * Interop between the signal hooks and the stock React-Redux API: + * `connect()`, `useSelector`, custom contexts, and nested providers. + * + * These import `Provider`/`useSelector`/`connect` from their source + * modules rather than `src/index`, so they are unaffected by the local + * benchmark override in `src/exports.ts`. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { createContext } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { connect } from '../../src/components/connect' +import type { ReactReduxContextValue } from '../../src/components/Context' +import Provider from '../../src/components/Provider' +import { createSelectorHook, useSelector } from '../../src/hooks/useSelector' +import { SignalProvider } from '../../src/signals/SignalProvider' +import { + createSignalSelectorHook, + useSignalSelector, +} from '../../src/signals/useSignalSelector' + +interface CounterState { + a: number + b: number +} + +const counterSlice = createSlice({ + name: 'counter', + initialState: { a: 0, b: 0 } as CounterState, + reducers: { + incA(state) { + state.a += 1 + }, + incB(state) { + state.b += 1 + }, + setA(state, action: PayloadAction) { + state.a = action.payload + }, + }, +}) + +const { incA, incB } = counterSlice.actions + +function makeStore() { + return configureStore({ + reducer: counterSlice.reducer, + // The tracking proxies hand frozen state to selectors already, and + // the immutability check walks the whole tree on every dispatch. + middleware: (getDefault) => + getDefault({ immutableCheck: false, serializableCheck: false }), + }) +} + +type Store = ReturnType + +describe('connect() under SignalProvider', () => { + it('renders and receives mapped state', () => { + const store = makeStore() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => ( +
{a}
+ )) + + rtl.render( + + + , + ) + + expect(rtl.screen.getByTestId('a').textContent).toBe('0') + }) + + it('updates when its mapped slice changes', () => { + const store = makeStore() + const renders = vi.fn() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => { + renders() + return
{a}
+ }) + + rtl.render( + + + , + ) + + rtl.act(() => { + store.dispatch(incA()) + }) + + expect(rtl.screen.getByTestId('a').textContent).toBe('1') + }) + + it('does not re-render when an unmapped slice changes', () => { + const store = makeStore() + const renders = vi.fn() + const mapState = (state: CounterState) => ({ a: state.a }) + const Display = connect(mapState)(({ a }: { a: number }) => { + renders() + return
{a}
+ }) + + rtl.render( + + + , + ) + const initial = renders.mock.calls.length + + rtl.act(() => { + store.dispatch(incB()) + }) + + expect(renders.mock.calls.length).toBe(initial) + }) + + it('sees raw state, not a tracking proxy, in mapStateToProps', () => { + const store = makeStore() + let seen: CounterState | undefined + const mapState = (state: CounterState) => { + seen = state + return { a: state.a } + } + const Display = connect(mapState)(({ a }: { a: number }) =>
{a}
) + + rtl.render( + + + , + ) + + expect(seen).toBe(store.getState()) + }) + + it('dispatches from a connected component and updates a sibling signal hook', () => { + const store = makeStore() + const Button = connect()( + ({ dispatch }: { dispatch: Store['dispatch'] }) => ( + + ), + ) + function SignalDisplay() { + const a = useSignalSelector((state: CounterState) => state.a) + return
{a}
+ } + + rtl.render( + + +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('name').textContent).toBe('one') + + rtl.fireEvent.click(getByTestId('both')) + + // Correct: the new closure (idx=1) selects items[1].name. + // A swap skipped via notify-origin tracking would show 'changed' + // (items[0]'s renamed value from the old closure's evaluation). + expect(getByTestId('name').textContent).toBe('two') + }) + + it('evaluates an inline selector twice per store-driven update (documented cost)', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + function Watcher() { + const name = useSignalSelector( + (s: AppState) => { + selectorCalls++ + return s.items.list[0].name + }, + { devModeChecks: { stabilityCheck: 'never' } }, + ) + return
{name}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + for (let i = 1; i <= 5; i++) { + rtl.act(() => { + store.dispatch(actions.renameFirst(`rename-${i}`)) + }) + } + + expect(getByTestId('name').textContent).toBe('rename-5') + // Two evaluations per update: effect (old closure, decides notify) + + // render-phase swap (new inline closure). + expect(selectorCalls - mountCalls).toBe(10) + }) + + it('evaluates a stable selector once per store-driven update', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + const selectFirstName = (s: AppState) => { + selectorCalls++ + return s.items.list[0].name + } + + function Watcher() { + const name = useSignalSelector(selectFirstName, { + devModeChecks: { stabilityCheck: 'never' }, + }) + return
{name}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + for (let i = 1; i <= 5; i++) { + rtl.act(() => { + store.dispatch(actions.renameFirst(`rename-${i}`)) + }) + } + + expect(getByTestId('name').textContent).toBe('rename-5') + // Stable reference: no swap, one effect evaluation per update. + expect(selectorCalls - mountCalls).toBe(5) + }) + + it('useCallback selector with closure deps evaluates once per update, twice when deps change', () => { + const { store, actions } = makeStore() + let selectorCalls = 0 + + function Watcher() { + const [idx, setIdx] = useState(0) + const selectName = useCallback( + (s: AppState) => { + selectorCalls++ + return s.items.list[idx].name + }, + [idx], + ) + const name = useSignalSelector(selectName, { + devModeChecks: { stabilityCheck: 'never' }, + }) + return ( +
+
{name}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + const mountCalls = selectorCalls + + // Store update with stable selector: one evaluation. + rtl.act(() => { + store.dispatch(actions.renameFirst('renamed')) + }) + expect(selectorCalls - mountCalls).toBe(1) + expect(getByTestId('name').textContent).toBe('renamed') + + // Dep change: new useCallback identity -> swap evaluates once. + rtl.fireEvent.click(getByTestId('switch')) + expect(selectorCalls - mountCalls).toBe(2) + expect(getByTestId('name').textContent).toBe('two') + }) +}) + +/** + * The render-phase swap re-evaluates the selector and adopts the result. + * That adoption has to go through the user's equality function, exactly + * like the effect path does. Skipping it hands React a brand-new + * reference on every render for any selector that builds its result + * (`() => [1, 2, 3]`), which is the case a custom equalityFn exists to + * defuse. + * + * Two assignment sites had to be covered, because `recomputeInPlace` + * sets `suppressNotify` and then bumps the version signal, which runs + * the driving effect synchronously. The effect's suppressNotify branch + * assigns first, so an equality check in only one of the two places + * still leaks the new reference through. + */ +describe('equality function on a render-phase swap', () => { + it('keeps the previous result when the equality function reports equal', () => { + const { store, actions } = makeStore() + const alwaysEqual = () => true + const seen: number[][] = [] + + function Watcher() { + // Drives a re-render on dispatch, so the swap below runs again. + const name = useSignalSelector((s: AppState) => s.items.list[0].name) + // A new closure AND a new array on every single render. + const items = useSignalSelector(() => [1, 2, 3], alwaysEqual) + seen.push(items) + return
{name}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + rtl.act(() => { + store.dispatch(actions.renameFirst('renamed')) + }) + + expect(getByTestId('name').textContent).toBe('renamed') + expect(seen.length).toBeGreaterThan(1) + for (const value of seen) { + expect(value).toBe(seen[0]) + } + }) + + it('adopts the new result when the equality function reports unequal', () => { + const { store } = makeStore() + const byFirstElement = (a: string[], b: string[]) => a[0] === b[0] + + function Watcher() { + const [index, setIndex] = useState(0) + // Inline selector closing over `index`: a new closure each render. + const [name] = useSignalSelector( + (s: AppState) => [s.items.list[index].name], + byFirstElement, + ) + return ( +
+
{name}
+ +
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('name').textContent).toBe('one') + + rtl.fireEvent.click(getByTestId('switch')) + expect(getByTestId('name').textContent).toBe('two') + }) +}) diff --git a/test/signals/reselect.spec.tsx b/test/signals/reselect.spec.tsx new file mode 100644 index 000000000..324c4bbe3 --- /dev/null +++ b/test/signals/reselect.spec.tsx @@ -0,0 +1,970 @@ +/** + * Reselect interop. Runs against both implementations via the + * `react-redux` alias in vitest.config.mts. + * + * The interesting interaction is in signals mode. Reselect memoizes a + * selector on its arguments (`argsMemoize`) before running any input + * selector, and the signals implementation passes a tracking proxy as + * the state argument. If two evaluations were handed the same proxy, the + * second would be an args-cache hit that reads no state properties, and + * the hook would record no dependencies. The implementation therefore + * hands every evaluation a fresh root proxy, so the args layer always + * misses, while child proxies stay cached by target identity so the + * result layer still memoizes on unchanged inputs. The tests here pin + * both halves: hooks keep updating, and result functions do not rerun + * for unrelated changes. + */ +import { + configureStore, + createEntityAdapter, + createSlice, +} from '@reduxjs/toolkit' +import { getProxyPath } from '@internal/signals/trackingProxy' +import * as rtl from '@testing-library/react' +import React from 'react' +import { Provider, shallowEqual, useSelector } from 'react-redux' +import { createSelector, lruMemoize, weakMapMemoize } from 'reselect' +import { afterEach, describe, expect, it } from 'vitest' + +interface Todo { + id: number + text: string + completed: boolean +} + +interface RootState { + todos: Todo[] + counter: { value: number } + filter: { showCompleted: boolean } +} + +const initialTodos: Todo[] = [ + { id: 1, text: 'write tests', completed: false }, + { id: 2, text: 'review code', completed: true }, +] + +const makeStore = () => + configureStore({ + reducer: { + todos: (state: Todo[] = initialTodos, action: any) => { + switch (action.type) { + case 'toggle': + return state.map((t) => + t.id === action.id ? { ...t, completed: !t.completed } : t, + ) + case 'add': + return [ + ...state, + { id: action.id, text: action.text, completed: false }, + ] + case 'editText': + return state.map((t) => + t.id === action.id ? { ...t, text: action.text } : t, + ) + default: + return state + } + }, + counter: (state: { value: number } = { value: 0 }, action: any) => + action.type === 'increment' ? { value: state.value + 1 } : state, + filter: ( + state: { showCompleted: boolean } = { showCompleted: true }, + action: any, + ) => + action.type === 'setFilter' ? { showCompleted: action.payload } : state, + }, + }) + +afterEach(() => { + rtl.cleanup() +}) + +describe('createSelector with the default weakMapMemoize', () => { + it('re-renders on relevant changes and skips irrelevant ones', () => { + const store = makeStore() + + const selectCompletedCount = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => t.completed).length, + ) + + let renderCount = 0 + function CompletedCount() { + renderCount++ + const count = useSelector(selectCompletedCount) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('count').textContent).toBe('1') + expect(renderCount).toBe(1) + + // Irrelevant slice: no re-render. + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(renderCount).toBe(1) + + // Relevant slice: exactly one re-render with the new derived value. + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('count').textContent).toBe('2') + expect(renderCount).toBe(2) + }) + + it('does not recompute the result function for unrelated dispatches', () => { + const store = makeStore() + + let resultRuns = 0 + const selectTodoTexts = createSelector( + [(s: RootState) => s.todos], + (todos) => { + resultRuns++ + return todos.map((t) => t.text) + }, + ) + + function Texts() { + const texts = useSelector(selectTodoTexts) + return
{texts.join(',')}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('texts').textContent).toBe('write tests,review code') + + const runsAfterMount = resultRuns + expect(runsAfterMount).toBe(1) + + // The counter slice is not an input; the todos array reference is + // unchanged, so the result function must not run again no matter + // how many times the wrapper selector itself is evaluated. + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(resultRuns).toBe(runsAfterMount) + + rtl.act(() => { + store.dispatch({ type: 'add', id: 3, text: 'ship it' }) + }) + expect(getByTestId('texts').textContent).toBe( + 'write tests,review code,ship it', + ) + expect(resultRuns).toBe(runsAfterMount + 1) + }) + + it('keeps delivering updates after memoization cache hits', () => { + // A memoization hit skips the selector body entirely — no state + // properties are touched on that evaluation. If the implementation + // rebuilt its dependency set from such a run, the hook would go + // deaf. Force extra evaluations with unchanged state (parent + // re-renders), then prove a relevant dispatch still lands. + const store = makeStore() + + const selectFirstTodoText = createSelector( + [(s: RootState) => s.todos], + (todos) => todos[0].text, + ) + + function Text() { + const text = useSelector(selectFirstTodoText) + return
{text}
+ } + + function App({ tick }: { tick: number }) { + return ( + +
{tick}
+ +
+ ) + } + + const { getByTestId, rerender } = rtl.render() + expect(getByTestId('text').textContent).toBe('write tests') + + // Same store state, new renders: every selector evaluation here is + // a weakMapMemoize cache hit. + rerender() + rerender() + expect(getByTestId('text').textContent).toBe('write tests') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + // toggle replaces the array and the todo object but keeps `text`; + // the derived value is unchanged. + expect(getByTestId('text').textContent).toBe('write tests') + + // A real change to the derived value must still come through — this + // is the assertion that fails if cache-hit evaluations wiped the + // hook's dependency tracking. + rtl.act(() => { + store.dispatch({ type: 'editText', id: 1, text: 'ship it' }) + }) + expect(getByTestId('text').textContent).toBe('ship it') + }) + + it('returns a stable derived object reference across components', () => { + const store = makeStore() + + const selectVisibleTodos = createSelector( + [(s: RootState) => s.todos, (s: RootState) => s.filter.showCompleted], + (todos, showCompleted) => + showCompleted ? todos : todos.filter((t) => !t.completed), + ) + + const seenA: Todo[][] = [] + const seenB: Todo[][] = [] + + function ListA() { + seenA.push(useSelector(selectVisibleTodos)) + return null + } + function ListB() { + seenB.push(useSelector(selectVisibleTodos)) + return null + } + + rtl.render( + + + + , + ) + + // Both components got the same object from the shared memoized + // selector, not two structurally-equal copies. + expect(seenA.at(-1)).toBe(seenB.at(-1)) + + rtl.act(() => { + store.dispatch({ type: 'setFilter', payload: false }) + }) + expect(seenA.at(-1)).toEqual([initialTodos[0]]) + expect(seenA.at(-1)).toBe(seenB.at(-1)) + }) + + it('re-renders exactly once when a derived object changes, with shared inputs', () => { + const store = makeStore() + + const selectIncomplete = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => !t.completed), + ) + + let renderCount = 0 + const results: Todo[][] = [] + function Incomplete() { + renderCount++ + const todos = useSelector(selectIncomplete) + results.push(todos) + return
{todos.length}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('incomplete').textContent).toBe('1') + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 2 }) + }) + expect(getByTestId('incomplete').textContent).toBe('2') + expect(renderCount).toBe(2) + expect(results.at(-1)).not.toBe(results[0]) + }) +}) + +describe('createSelector with lruMemoize', () => { + it('behaves the same as weakMapMemoize for a single subscriber', () => { + const store = makeStore() + + let resultRuns = 0 + const selectCounterDouble = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return value * 2 + }, + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + // Reselect's own dev-mode identity check calls the result + // function one extra time on the first invocation; disable it + // so the run counts below stay exact. + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + let renderCount = 0 + function Double() { + renderCount++ + const doubled = useSelector(selectCounterDouble) + return
{doubled}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('double').textContent).toBe('0') + expect(resultRuns).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(renderCount).toBe(1) + expect(resultRuns).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(getByTestId('double').textContent).toBe('2') + expect(renderCount).toBe(2) + expect(resultRuns).toBe(2) + }) +}) + +describe('layered memoized selectors', () => { + it('a selector composed from other memoized selectors updates correctly', () => { + const store = makeStore() + + const selectTodos = (s: RootState) => s.todos + const selectCompleted = createSelector([selectTodos], (todos) => + todos.filter((t) => t.completed), + ) + const selectCompletedCount = createSelector( + [selectCompleted], + (completed) => completed.length, + ) + const selectSummary = createSelector( + [selectTodos, selectCompletedCount], + (all, completedCount) => `${completedCount}/${all.length}`, + ) + + let renderCount = 0 + function Summary() { + renderCount++ + const summary = useSelector(selectSummary) + return
{summary}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + expect(getByTestId('summary').textContent).toBe('1/2') + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + expect(renderCount).toBe(1) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('summary').textContent).toBe('2/2') + expect(renderCount).toBe(2) + + rtl.act(() => { + store.dispatch({ type: 'add', id: 3, text: 'third' }) + }) + expect(getByTestId('summary').textContent).toBe('2/3') + expect(renderCount).toBe(3) + }) + + it('an explicitly weakMapMemoize-configured selector works with per-item lookups', () => { + const store = makeStore() + + const selectTodoById = createSelector( + [(s: RootState) => s.todos, (_s: RootState, id: number) => id], + (todos, id) => todos.find((t) => t.id === id), + { memoize: weakMapMemoize, argsMemoize: weakMapMemoize }, + ) + + function TodoText({ id }: { id: number }) { + const todo = useSelector((s: RootState) => selectTodoById(s, id)) + return
{todo?.text}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('todo-1').textContent).toBe('write tests') + expect(getByTestId('todo-2').textContent).toBe('review code') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('todo-1').textContent).toBe('write tests') + expect(getByTestId('todo-2').textContent).toBe('review code') + }) +}) + +describe('args-memoization cache hits', () => { + // Reselect memoizes the whole selector on its arguments (argsMemoize) + // before it even runs the input selectors. If two hook evaluations hand + // it the same state argument, the second is an args-cache hit: no input + // selector runs, no state property is read, and a tracking hook would + // record nothing. Every test here dispatches several times so that a + // hook that tracked correctly once and then went deaf still fails. + const selectSummary = () => + createSelector([(s: RootState) => s.counter.value], (value) => ({ + doubled: value * 2, + })) + + const incrementTimes = (store: ReturnType, n: number) => { + for (let i = 0; i < n; i++) { + rtl.act(() => { + store.dispatch({ type: 'increment' }) + }) + } + } + + it('a selector shared by two components keeps updating both', () => { + const store = makeStore() + const selectDoubled = selectSummary() + let rendersA = 0 + let rendersB = 0 + + function A() { + rendersA++ + return
{useSelector(selectDoubled).doubled}
+ } + function B() { + rendersB++ + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + expect(rendersA).toBe(4) + expect(rendersB).toBe(4) + }) + + it('a component mounted after the selector is already cached still updates', () => { + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + return
{useSelector(selectDoubled).doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + function App({ showB }: { showB: boolean }) { + return ( + +
+ {showB && } + + ) + } + + const { getByTestId, rerender } = rtl.render() + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe('2') + + // A has already evaluated selectDoubled against the current state. + rerender() + expect(getByTestId('b').textContent).toBe('2') + + for (const expected of ['4', '6', '8']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + }) + + it('an inline wrapper around a memoized selector keeps updating', () => { + // The inline arrow is a new function every render, so after each + // dispatch the hook re-evaluates against the SAME state a second time. + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + } + }) + + it('a parametrized (state, arg) memoized selector keeps updating', () => { + const store = makeStore() + let resultRuns = 0 + const selectScaled = createSelector( + [ + (s: RootState) => s.counter.value, + (_s: RootState, factor: number) => factor, + ], + (value, factor) => { + resultRuns++ + return { scaled: value * factor } + }, + ) + + function Scaled({ factor }: { factor: number }) { + const scaled = useSelector( + (s: RootState) => selectScaled(s, factor).scaled, + ) + return
{scaled}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('x2').textContent).toBe('0') + expect(getByTestId('x3').textContent).toBe('0') + + for (const value of [1, 2, 3]) { + incrementTimes(store, 1) + expect(getByTestId('x2').textContent).toBe(String(value * 2)) + expect(getByTestId('x3').textContent).toBe(String(value * 3)) + } + // The result function is memoized on the input VALUES, so it runs + // once per (value, factor) pair regardless of how many times the + // wrapper selector was evaluated. + expect(resultRuns).toBe(8) + }) + + it('nested memoized input selectors keep updating', () => { + const store = makeStore() + const selectIncompleteCount = createSelector( + [(s: RootState) => s.todos], + (todos) => todos.filter((t) => !t.completed).length, + ) + const selectCounterValue = createSelector( + [(s: RootState) => s.counter], + (counter) => counter.value, + ) + const selectReport = createSelector( + [selectIncompleteCount, selectCounterValue], + (incomplete, value) => ({ + label: `${incomplete} open / ${value} clicks`, + }), + ) + + function Report() { + return
{useSelector(selectReport).label}
+ } + function Mirror() { + return
{useSelector(selectReport).label}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + expect(getByTestId('report').textContent).toBe('1 open / 0 clicks') + + incrementTimes(store, 2) + expect(getByTestId('report').textContent).toBe('1 open / 2 clicks') + expect(getByTestId('mirror').textContent).toBe('1 open / 2 clicks') + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 2 }) + }) + expect(getByTestId('report').textContent).toBe('2 open / 2 clicks') + expect(getByTestId('mirror').textContent).toBe('2 open / 2 clicks') + + incrementTimes(store, 1) + expect(getByTestId('report').textContent).toBe('2 open / 3 clicks') + expect(getByTestId('mirror').textContent).toBe('2 open / 3 clicks') + }) + + it('lruMemoize for both memoize and argsMemoize keeps updating', () => { + const store = makeStore() + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => ({ doubled: value * 2 }), + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + }) + + it.each([ + ['weakMapMemoize', 'lruMemoize', weakMapMemoize, lruMemoize], + ['lruMemoize', 'weakMapMemoize', lruMemoize, weakMapMemoize], + ] as const)( + 'memoize: %s with argsMemoize: %s keeps updating', + (_m, _a, memoize, argsMemoize) => { + const store = makeStore() + let resultRuns = 0 + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return { doubled: value * 2 } + }, + { + memoize, + argsMemoize, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + function A() { + const doubled = useSelector((s: RootState) => selectDoubled(s).doubled) + return
{doubled}
+ } + function B() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ + , + ) + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + expect(getByTestId('b').textContent).toBe(expected) + } + // The result layer keys on the input value, so it runs once per + // distinct counter value no matter which args layer sits above it. + expect(resultRuns).toBe(4) + }, + ) + + it('lruMemoize args with shallowEqual keeps updating, at the cost of coarse tracking', () => { + // shallowEqual has to enumerate the root and read every top-level + // slice to compare two state arguments, so the hook depends on all of + // them. It re-evaluates on every dispatch but only re-renders when the + // derived value changes. + const store = makeStore() + let resultRuns = 0 + const selectDoubled = createSelector( + [(s: RootState) => s.counter.value], + (value) => { + resultRuns++ + return { doubled: value * 2 } + }, + { + memoize: lruMemoize, + argsMemoize: lruMemoize, + argsMemoizeOptions: { equalityCheck: shallowEqual }, + devModeChecks: { identityFunctionCheck: 'never' }, + }, + ) + + let renders = 0 + function A() { + renders++ + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + expect(renders).toBe(1) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + expect(getByTestId('a').textContent).toBe(expected) + } + expect(renders).toBe(4) + expect(resultRuns).toBe(4) + + rtl.act(() => { + store.dispatch({ type: 'toggle', id: 1 }) + }) + expect(getByTestId('a').textContent).toBe('6') + expect(renders).toBe(4) + expect(resultRuns).toBe(4) + }) + + it('a selector memoized on a slice, not the root, stays correct and skips unrelated slices', () => { + // Here the cache key is `s.todos`, which the hook reads before calling + // the memoized helper, so that read is tracked even when the helper + // itself short-circuits. + const store = makeStore() + let resultRuns = 0 + const selectTexts = createSelector([(todos: Todo[]) => todos], (todos) => { + resultRuns++ + return todos.map((t) => t.text).join(',') + }) + + let renders = 0 + function Texts() { + renders++ + const texts = useSelector((s: RootState) => selectTexts(s.todos)) + return
{texts}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + expect(getByTestId('texts').textContent).toBe('write tests,review code') + expect(renders).toBe(1) + expect(resultRuns).toBe(1) + + incrementTimes(store, 2) + expect(renders).toBe(1) + expect(resultRuns).toBe(1) + + for (const [id, text] of [ + [1, 'ship it'], + [2, 'merge it'], + ] as const) { + rtl.act(() => { + store.dispatch({ type: 'editText', id, text }) + }) + } + expect(getByTestId('texts').textContent).toBe('ship it,merge it') + expect(renders).toBe(3) + expect(resultRuns).toBe(3) + }) + + it('calling the same memoized selector with raw state outside React does not break the hook', () => { + const store = makeStore() + const selectDoubled = selectSummary() + + function A() { + return
{useSelector(selectDoubled).doubled}
+ } + + const { getByTestId } = rtl.render( + +
+ , + ) + + for (const expected of ['2', '4', '6']) { + incrementTimes(store, 1) + // A thunk or listener reading through the same selector with the + // raw state populates a separate cache entry. + expect(selectDoubled(store.getState()).doubled).toBe(Number(expected)) + expect(getByTestId('a').textContent).toBe(expected) + } + }) + + it('documented limitation: a cache that never reads state cannot be tracked', () => { + // Same result in both implementations — stock useSelector returns the + // stale cached object too. Pinned so the behavior is deliberate. + const store = makeStore() + let cached: { doubled: number } | undefined + const selectOnce = (s: RootState) => { + if (cached === undefined) cached = { doubled: s.counter.value * 2 } + return cached + } + + function A() { + return
{useSelector(selectOnce).doubled}
+ } + const { getByTestId } = rtl.render( + +
+ , + ) + incrementTimes(store, 2) + expect(getByTestId('a').textContent).toBe('0') + }) +}) + +describe('entity adapter selectors feeding result functions', () => { + // The derived-selectors benchmark shape: `selectAll` (ids.map(id => + // entities[id])) feeds result functions that filter the whole array. + // In signals mode the arrays must reach the result function as raw + // objects, or every element read becomes a proxy trap plus a signal + // registration, per component, per dispatch. + interface Post { + id: number + title: string + score: number + } + interface Comment { + id: number + postId: number + } + + const postsAdapter = createEntityAdapter() + const commentsAdapter = createEntityAdapter() + + const postsSlice = createSlice({ + name: 'posts', + initialState: postsAdapter.getInitialState(undefined, [ + { id: 1, title: 'one', score: 10 }, + { id: 2, title: 'two', score: 20 }, + { id: 3, title: 'three', score: 30 }, + ]), + reducers: { updateScore: postsAdapter.updateOne }, + }) + const commentsSlice = createSlice({ + name: 'comments', + initialState: commentsAdapter.getInitialState(undefined, [ + { id: 1, postId: 1 }, + { id: 2, postId: 1 }, + { id: 3, postId: 2 }, + ]), + reducers: { addComment: commentsAdapter.addOne }, + }) + + const makeEntityStore = () => + configureStore({ + reducer: { posts: postsSlice.reducer, comments: commentsSlice.reducer }, + }) + type EntityState = ReturnType['getState']> + + const { selectById: selectPostById } = postsAdapter.getSelectors( + (s: EntityState) => s.posts, + ) + const { selectAll: selectAllComments } = commentsAdapter.getSelectors( + (s: EntityState) => s.comments, + ) + + it('result functions receive raw arrays and every consumer keeps updating', () => { + const store = makeEntityStore() + let resultRuns = 0 + let sawProxy = false + const selectPostWithComments = createSelector( + [ + (s: EntityState, postId: number) => selectPostById(s, postId), + selectAllComments, + ], + (post, comments) => { + resultRuns++ + if ( + getProxyPath(comments) !== undefined || + comments.some((c) => getProxyPath(c) !== undefined) + ) { + sawProxy = true + } + return { + ...post, + commentCount: comments.filter((c) => c.postId === post.id).length, + } + }, + ) + + const renders: Record = { 1: 0, 2: 0, 3: 0 } + function PostCard({ postId }: { postId: number }) { + renders[postId]++ + const data = useSelector((s: EntityState) => + selectPostWithComments(s, postId), + ) + return ( +
+ {data.title}:{data.score}:{data.commentCount} +
+ ) + } + + const { getByTestId } = rtl.render( + + + + + , + ) + expect(getByTestId('post-1').textContent).toBe('one:10:2') + expect(getByTestId('post-2').textContent).toBe('two:20:1') + expect(getByTestId('post-3').textContent).toBe('three:30:0') + expect(sawProxy).toBe(false) + expect(renders).toEqual({ 1: 1, 2: 1, 3: 1 }) + resultRuns = 0 + + // Every post's result function depends on the comments array, so all + // three recompute and, because each returns a fresh object, all three + // re-render (same as stock). + rtl.act(() => { + store.dispatch(commentsSlice.actions.addComment({ id: 4, postId: 3 })) + }) + expect(getByTestId('post-3').textContent).toBe('three:30:1') + expect(resultRuns).toBe(3) + expect(renders).toEqual({ 1: 2, 2: 2, 3: 2 }) + expect(sawProxy).toBe(false) + resultRuns = 0 + + // A score change replaces one post entity: one recompute, one render. + rtl.act(() => { + store.dispatch( + postsSlice.actions.updateScore({ id: 2, changes: { score: 25 } }), + ) + }) + expect(getByTestId('post-2').textContent).toBe('two:25:1') + expect(resultRuns).toBe(1) + expect(renders).toEqual({ 1: 2, 2: 3, 3: 2 }) + resultRuns = 0 + + rtl.act(() => { + store.dispatch(commentsSlice.actions.addComment({ id: 5, postId: 2 })) + }) + expect(getByTestId('post-2').textContent).toBe('two:25:2') + expect(resultRuns).toBe(3) + expect(renders).toEqual({ 1: 3, 2: 4, 3: 3 }) + expect(sawProxy).toBe(false) + }) +}) diff --git a/test/signals/rtkQueryInterop.spec.tsx b/test/signals/rtkQueryInterop.spec.tsx new file mode 100644 index 000000000..1b5fe1c28 --- /dev/null +++ b/test/signals/rtkQueryInterop.spec.tsx @@ -0,0 +1,870 @@ +/** + * RTK Query opting into the signal hooks per API via + * `reactHooksModule({ hooks: { useSelector, useDispatch, useStore } })`. + * + * `useSelector` here comes from the aliased `react-redux` entry, so the + * same file runs against stock `useSelector` (default) and + * `useSignalSelector` (`TEST_IMPL=signals`). Render counts are asserted + * exactly and must match in both modes. Registry assertions only run in + * signals mode. + * + * `useDispatch`/`useStore` are the stock hooks in BOTH modes: the point + * of the per-API opt-in is that only the selector hook changes. + */ +import * as rtl from '@testing-library/react' +import React from 'react' +import { combineReducers, configureStore, createSlice } from '@reduxjs/toolkit' +import type { BaseQueryFn } from '@reduxjs/toolkit/query/react' +import { + buildCreateApi, + coreModule, + reactHooksModule, +} from '@reduxjs/toolkit/query/react' +import { Provider, useSelector } from 'react-redux' +import { useDispatch, useStore } from '../../src/index' +import { unwrap } from '../../src/signals/trackingProxy' +import { useSignalContext } from '../../src/signals/context' +import type { + PathSignalRegistry, + RegistryStats, +} from '../../src/signals/pathSignalRegistry' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const IS_SIGNALS = process.env.TEST_IMPL === 'signals' + +interface Post { + id: number + title: string + body: string +} + +interface User { + id: number + name: string +} + +interface Request { + url: string + method: string + body?: unknown +} + +function makeServer() { + const posts = new Map([ + [1, { id: 1, title: 'first', body: 'b1' }], + [2, { id: 2, title: 'second', body: 'b2' }], + [3, { id: 3, title: 'third', body: 'b3' }], + ]) + const users = new Map([[7, { id: 7, name: 'ann' }]]) + const requests: Request[] = [] + let gate: Promise | null = null + let openGate: (() => void) | null = null + + const baseQuery: BaseQueryFn = async ( + req, + ) => { + requests.push(req) + await Promise.resolve() + if (gate) await gate + const [, resource, rawId] = req.url.split('/') + if (resource === 'posts') { + if (rawId === undefined) { + return { data: Array.from(posts.values()) } + } + const id = Number(rawId) + const post = posts.get(id) + if (!post) return { error: { status: 404 } } + if (req.method === 'PATCH') { + const next = { ...post, ...(req.body as Partial) } + posts.set(id, next) + return { data: next } + } + return { data: post } + } + if (resource === 'users') { + const user = users.get(Number(rawId)) + return user ? { data: user } : { error: { status: 404 } } + } + return { error: { status: 404 } } + } + + function pause() { + gate = new Promise((resolve) => { + openGate = resolve + }) + } + function resume() { + openGate?.() + gate = null + openGate = null + } + function resumeOnce() { + const release = openGate + pause() + release?.() + } + + return { posts, users, requests, baseQuery, pause, resume, resumeOnce } +} + +function makeApi(baseQuery: BaseQueryFn) { + const createApi = buildCreateApi( + coreModule(), + reactHooksModule({ hooks: { useDispatch, useSelector, useStore } }), + ) + + return createApi({ + reducerPath: 'api', + baseQuery, + tagTypes: ['Post'], + endpoints: (build) => ({ + getPosts: build.query({ + query: () => ({ url: '/posts', method: 'GET' }), + providesTags: (result) => + result + ? [ + { type: 'Post', id: 'LIST' }, + ...result.map((p) => ({ type: 'Post' as const, id: p.id })), + ] + : [{ type: 'Post', id: 'LIST' }], + }), + getPost: build.query({ + query: (id) => ({ url: `/posts/${id}`, method: 'GET' }), + providesTags: (_r, _e, id) => [{ type: 'Post', id }], + }), + getUser: build.query({ + query: (id) => ({ url: `/users/${id}`, method: 'GET' }), + }), + updatePost: build.mutation({ + query: ({ id, ...body }) => ({ + url: `/posts/${id}`, + method: 'PATCH', + body, + }), + invalidatesTags: (_r, _e, { id }) => [{ type: 'Post', id }], + }), + }), + }) +} + +type Api = ReturnType + +const counterSlice = createSlice({ + name: 'counter', + initialState: { value: 0 }, + reducers: { + increment(state) { + state.value += 1 + }, + }, +}) + +function makeStore(api: Api) { + return configureStore({ + reducer: combineReducers({ + [api.reducerPath]: api.reducer, + counter: counterSlice.reducer, + }), + middleware: (gDM) => gDM().concat(api.middleware), + }) +} + +type AppStore = ReturnType + +const EMPTY_STATS: RegistryStats = { + signals: 0, + prefixCounts: 0, + prefixOnlyPaths: 0, + childIndex: 0, + arrayMetas: 0, + columnsByArray: 0, + structuresByArray: 0, + segmentSubs: 0, +} + +let capturedRegistry: PathSignalRegistry | null = null + +function CaptureRegistry() { + capturedRegistry = useSignalContext().registry + return null +} + +function requireRegistry(): PathSignalRegistry { + if (capturedRegistry === null) throw new Error('registry not captured') + return capturedRegistry +} + +const renders: Record = {} +function countRender(name: string) { + renders[name] = (renders[name] ?? 0) + 1 +} + +let server: ReturnType +let api: Api +let store: AppStore + +beforeEach(() => { + server = makeServer() + api = makeApi(server.baseQuery) + store = makeStore(api) + capturedRegistry = null + for (const key of Object.keys(renders)) delete renders[key] +}) + +afterEach(() => { + rtl.cleanup() + vi.restoreAllMocks() +}) + +function Root({ children }: { children: React.ReactNode }) { + return ( + + {IS_SIGNALS ? : null} + {children} + + ) +} + +function PostTitle({ id, skip = false }: { id: number; skip?: boolean }) { + countRender(`post${id}`) + const { data, isLoading, isFetching, isUninitialized } = + api.endpoints.getPost.useQuery(id, { skip }) + return ( +
+ {isUninitialized + ? 'uninitialized' + : isLoading + ? 'loading' + : `${data?.title ?? ''}${isFetching ? ' (refetching)' : ''}`} +
+ ) +} + +function UserName({ id }: { id: number }) { + countRender(`user${id}`) + const { data, isLoading } = api.endpoints.getUser.useQuery(id) + return ( +
{isLoading ? 'loading' : data?.name}
+ ) +} + +function PostTitleFromList({ id }: { id: number }) { + countRender(`list${id}`) + const { title, isLoading } = api.endpoints.getPosts.useQuery(undefined, { + selectFromResult: ({ data, isLoading }) => ({ + title: data?.find((p) => p.id === id)?.title, + isLoading, + }), + }) + return
{isLoading ? 'loading' : title}
+} + +function Counter() { + countRender('counter') + const value = useSelector( + (s: ReturnType) => s.counter.value, + ) + return
{value}
+} + +// configureStore's autoBatch enhancer defers subscriber notification for +// RTKQ's fulfilled/patched actions to a requestAnimationFrame tick, so a +// plain act() returns before the store notifies. Wait past two frames. +async function settle() { + await rtl.act(async () => { + await new Promise((r) => + requestAnimationFrame(() => requestAnimationFrame(() => r())), + ) + }) +} + +async function dispatchAndSettle(dispatch: () => unknown) { + await rtl.act(async () => { + dispatch() + }) + await settle() +} + +describe('RTK Query with reactHooksModule({ hooks: { useSelector } })', () => { + it('fetches on mount and re-renders once per phase (pending, fulfilled)', async () => { + server.pause() + rtl.render( + + + , + ) + // Mount, then the pending entry (dispatched from the subscription + // effect inside render's act) replaces the synthetic "uninitialized + // shown as pending" result: same flags, but requestId/startedTimeStamp + // appear, so shallowEqual sees a change. + expect(rtl.screen.getByTestId('post1').textContent).toBe('loading') + expect(renders.post1).toBe(2) + + await settle() + expect(renders.post1).toBe(2) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(renders.post1).toBe(3) + expect(server.requests).toHaveLength(1) + }) + + it('hands the component raw cache data, not a tracking proxy, with stable identity', async () => { + const seen: Array = [] + function Capture() { + const { data } = api.endpoints.getPost.useQuery(1) + seen.push(data) + return null + } + const { rerender } = rtl.render( + + + , + ) + await settle() + const first = seen.at(-1) + expect(first).toEqual({ id: 1, title: 'first', body: 'b1' }) + + const raw = api.endpoints.getPost.select(1)(store.getState()).data + expect(first).toBe(raw) + expect(unwrap(first)).toBe(first) + + // A parent-driven re-render hands back the same reference + rerender( + + + , + ) + expect(seen.at(-1)).toBe(first) + }) + + it('returns a stable result object across unrelated store updates', async () => { + const results: unknown[] = [] + function Capture() { + const result = api.endpoints.getPost.useQuery(1) + results.push(result) + return null + } + rtl.render( + + + , + ) + await settle() + const settled = results.at(-1) + const count = results.length + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getUser', 7, () => ({ id: 7, name: 'x' })), + ), + ) + expect(results.length).toBe(count) + expect(results.at(-1)).toBe(settled) + }) + + describe('selectFromResult', () => { + it('re-renders only the subscriber whose picked element changed', async () => { + rtl.render( + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('list1').textContent).toBe('first') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second') + expect(server.requests).toHaveLength(1) + expect(renders.list1).toBe(3) + expect(renders.list2).toBe(3) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPosts', undefined, (draft) => { + const p = draft.find((p) => p.id === 2) + if (p) p.title = 'second!' + }), + ), + ) + + expect(rtl.screen.getByTestId('list1').textContent).toBe('first') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second!') + expect(renders.list1).toBe(3) + expect(renders.list2).toBe(4) + }) + + it('does not re-render when a field the selector did not pick changes', async () => { + function BodyOnly() { + countRender('body') + const { body } = api.endpoints.getPost.useQuery(1, { + selectFromResult: ({ data }) => ({ body: data?.body }), + }) + return
{body}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('body').textContent).toBe('b1') + const before = renders.body + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'retitled' + }), + ), + ) + expect(renders.body).toBe(before) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.body = 'b1!' + }), + ), + ) + expect(rtl.screen.getByTestId('body').textContent).toBe('b1!') + expect(renders.body).toBe(before + 1) + }) + + it('accepts a selector that spreads and destructures the result', async () => { + let received: Record = {} + function Spread() { + countRender('spread') + const result = api.endpoints.getPost.useQuery(1, { + selectFromResult: ({ data, ...rest }) => ({ + ...rest, + title: data?.title, + hasBody: data !== undefined && 'body' in data, + }), + }) + received = result + return
{result.title}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('spread').textContent).toBe('first') + expect(received.hasBody).toBe(true) + expect(received.isSuccess).toBe(true) + expect('data' in received).toBe(false) + const before = renders.spread + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + expect(renders.spread).toBe(before) + }) + + it('works with a stable selector defined outside the component', async () => { + const selectTitle = ({ + data, + isFetching, + }: { + data?: Post + isFetching: boolean + }) => ({ title: data?.title, isFetching }) + + function Stable() { + countRender('stable') + const { title } = api.endpoints.getPost.useQuery(1, { + selectFromResult: selectTitle, + }) + return
{title}
+ } + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('stable').textContent).toBe('first') + expect(renders.stable).toBe(3) + }) + }) + + // While a new arg loads, RTKQ feeds the previous default result back into + // its selector as `lastResult` and keeps showing that result's `data`. + // With selectFromResult, that previous default result is captured inside + // the selector run, so under signals its `data` is the tracking proxy + // from an earlier evaluation. These check it never reaches the component + // and that behavior matches stock. + describe('arg change with selectFromResult', () => { + const seen: Array = [] + const pickPost = ({ + data, + isFetching, + }: { + data?: Post + isFetching: boolean + }) => ({ post: data, isFetching }) + + function Picker({ id, stable }: { id: number; stable: boolean }) { + countRender('picker') + const { post, isFetching } = api.endpoints.getPost.useQuery(id, { + selectFromResult: stable + ? pickPost + : ({ data, isFetching }) => ({ post: data, isFetching }), + }) + seen.push(post) + return ( +
+ {post?.title ?? ''} + {isFetching ? ' (fetching)' : ''} +
+ ) + } + + // mount, pending, fulfilled | new arg, its pending | fulfilled, back to 1 + const PICKER_RENDERS = { before: 3, whileLoading: 5, final: 7 } + + const rawPost = (id: number) => + api.endpoints.getPost.select(id)(store.getState()).data + + beforeEach(() => { + seen.length = 0 + }) + + describe.each([ + ['inline', false], + ['stable', true], + ])('%s selector', (_name, stable) => { + it('keeps the previous raw data while the new arg loads, then switches', async () => { + const { rerender } = rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('first') + const post1 = rawPost(1) + expect(seen.at(-1)).toBe(post1) + const before = renders.picker + + server.pause() + rerender( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe( + 'first (fetching)', + ) + expect(seen.at(-1)).toBe(post1) + expect(unwrap(seen.at(-1))).toBe(seen.at(-1)) + const whileLoading = renders.picker + + server.resume() + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('second') + expect(seen.at(-1)).toBe(rawPost(2)) + expect(unwrap(seen.at(-1))).toBe(seen.at(-1)) + expect(renders.picker).toBe(whileLoading + 1) + + // Back to the cached arg: immediate, no request, raw data + rerender( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('first') + expect(seen.at(-1)).toBe(post1) + expect(server.requests).toHaveLength(2) + expect({ before, whileLoading, final: renders.picker }).toEqual( + PICKER_RENDERS, + ) + }) + + it('ignores patches to the previous entry while the new arg loads', async () => { + const { rerender } = rtl.render( + + + , + ) + await settle() + server.pause() + rerender( + + + , + ) + await settle() + const post1 = seen.at(-1) + const whileLoading = renders.picker + + // The shown data is the previous result object, not a live read of + // entry 1, so patching entry 1 changes nothing on screen. + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'patched' + }), + ), + ) + expect(rtl.screen.getByTestId('picker').textContent).toBe( + 'first (fetching)', + ) + expect(seen.at(-1)).toBe(post1) + expect(renders.picker).toBe(whileLoading) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('picker').textContent).toBe('second') + }) + }) + }) + + it('runs a mutation, invalidates, and refetches the dependent query', async () => { + let trigger: ((arg: { id: number; title: string }) => unknown) | null = null + function Editor() { + countRender('editor') + const [update, { isUninitialized, isLoading, isSuccess, data }] = + api.endpoints.updatePost.useMutation() + trigger = update + return ( +
+ {isUninitialized + ? 'idle' + : isLoading + ? 'saving' + : isSuccess + ? `saved:${data.title}` + : 'error'} +
+ ) + } + + rtl.render( + + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('post2').textContent).toBe('second') + expect(rtl.screen.getByTestId('editor').textContent).toBe('idle') + expect(renders.editor).toBe(1) + const post1Before = renders.post1 + const post2Before = renders.post2 + + server.pause() + await rtl.act(async () => { + trigger!({ id: 1, title: 'renamed' }) + }) + await settle() + expect(rtl.screen.getByTestId('editor').textContent).toBe('saving') + expect(renders.editor).toBe(2) + + // Release the PATCH; the invalidation refetch of post 1 is gated again + server.resumeOnce() + await settle() + expect(rtl.screen.getByTestId('editor').textContent).toBe('saved:renamed') + expect(renders.editor).toBe(3) + expect(rtl.screen.getByTestId('post1').textContent).toBe( + 'first (refetching)', + ) + expect(renders.post1).toBe(post1Before + 1) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('renamed') + expect(renders.post1).toBe(post1Before + 2) + expect(rtl.screen.getByTestId('post2').textContent).toBe('second') + expect(renders.post2).toBe(post2Before) + expect(server.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'GET /posts/1', + 'GET /posts/2', + 'PATCH /posts/1', + 'GET /posts/1', + ]) + }) + + it('shares one cache entry between two subscribers', async () => { + function Second() { + countRender('second') + const { data } = api.endpoints.getPost.useQuery(1) + return
{data?.title}
+ } + rtl.render( + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('second').textContent).toBe('first') + expect(server.requests).toHaveLength(1) + expect(renders.post1).toBe(3) + expect(renders.second).toBe(3) + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getPost', 1, (draft) => { + draft.title = 'patched' + }), + ), + ) + expect(rtl.screen.getByTestId('post1').textContent).toBe('patched') + expect(rtl.screen.getByTestId('second').textContent).toBe('patched') + expect(renders.post1).toBe(4) + expect(renders.second).toBe(4) + }) + + it('does not request while skipped, then fetches once skip is lifted', async () => { + const { rerender } = rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('post3').textContent).toBe('uninitialized') + await settle() + expect(server.requests).toHaveLength(0) + expect(renders.post3).toBe(1) + + server.pause() + rerender( + + + , + ) + // `queryThunk.pending` is autoBatched, so its store notification is + // deferred to a frame. The pending phase still renders inside + // `rerender`: React's post-commit snapshot check reads the current + // store state directly. + expect(rtl.screen.getByTestId('post3').textContent).toBe('loading') + expect(renders.post3).toBe(3) + expect(server.requests).toHaveLength(1) + await settle() + expect(renders.post3).toBe(3) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post3').textContent).toBe('third') + expect(renders.post3).toBe(4) + }) + + it('only re-renders the component whose cache entry changed', async () => { + rtl.render( + + + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + const post1 = renders.post1 + const user7 = renders.user7 + const counter = renders.counter + + await dispatchAndSettle(() => + store.dispatch( + api.util.updateQueryData('getUser', 7, (draft) => { + draft.name = 'bob' + }), + ), + ) + expect(rtl.screen.getByTestId('user7').textContent).toBe('bob') + expect(renders.user7).toBe(user7 + 1) + expect(renders.post1).toBe(post1) + expect(renders.counter).toBe(counter) + + await dispatchAndSettle(() => + store.dispatch(counterSlice.actions.increment()), + ) + expect(renders.counter).toBe(counter + 1) + expect(renders.post1).toBe(post1) + expect(renders.user7).toBe(user7 + 1) + }) + + it('re-renders for the refetching and fulfilled phases of a same-value refetch', async () => { + rtl.render( + + + , + ) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + const before = renders.post1 + + server.pause() + await rtl.act(async () => { + store.dispatch(api.endpoints.getPost.initiate(1, { forceRefetch: true })) + }) + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe( + 'first (refetching)', + ) + expect(renders.post1).toBe(before + 1) + + server.resume() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(renders.post1).toBe(before + 2) + expect(server.requests).toHaveLength(2) + }) + + it.runIf(IS_SIGNALS)( + 'releases every path signal and coarse subscriber on unmount', + async () => { + const { unmount } = rtl.render( + + + + + , + ) + const registry = requireRegistry() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + expect(rtl.screen.getByTestId('list2').textContent).toBe('second') + const live = registry.debugStats() + expect(live.signals).toBeGreaterThan(0) + + unmount() + + const after = registry.debugStats() + // Array metadata is retained by design (see registryLeak.spec.tsx) + expect({ ...after, arrayMetas: 0 }).toEqual(EMPTY_STATS) + }, + ) + + it.runIf(IS_SIGNALS)( + 'tracks only the queried cache entries, not the whole queries map', + async () => { + rtl.render( + + + + , + ) + const registry = requireRegistry() + await settle() + expect(rtl.screen.getByTestId('post1').textContent).toBe('first') + expect(rtl.screen.getByTestId('user7').textContent).toBe('ann') + const paths = registry.debugPaths() + expect(paths.some((p) => p.startsWith('api.queries.'))).toBe(true) + expect(paths).not.toContain('api.queries') + expect(paths).not.toContain('api') + expect(paths.some((p) => p.startsWith('api.subscriptions'))).toBe(false) + expect(paths.some((p) => p.startsWith('api.provided'))).toBe(false) + }, + ) +}) diff --git a/test/signals/snapshotFreshness.spec.tsx b/test/signals/snapshotFreshness.spec.tsx new file mode 100644 index 000000000..aa51fe20b --- /dev/null +++ b/test/signals/snapshotFreshness.spec.tsx @@ -0,0 +1,251 @@ +/** + * A store enhancer may notify listeners some time after `dispatch` + * returns (`autoBatchEnhancer` in RTK does this for every RTK Query + * lifecycle action). Any render that happens inside that gap must still + * read the CURRENT store state: `getSnapshot` is the source of truth for + * React, not the notification. Runs against both implementations. + */ +import * as rtl from '@testing-library/react' +import * as React from 'react' +import type { StoreEnhancer } from 'redux' +import { createStore } from 'redux' +import { Provider, useSelector } from 'react-redux' +import { useSelector as useStockSelector } from '../../src/index' +import { afterEach, describe, expect, it } from 'vitest' + +afterEach(() => { + rtl.cleanup() +}) + +interface State { + value: number + other: string + item: { label: string } +} + +const reducer = ( + state: State = { value: 0, other: 'x', item: { label: 'a' } }, + action: { type: string }, +): State => { + switch (action.type) { + case 'inc': + return { ...state, value: state.value + 1 } + case 'other': + return { ...state, other: state.other + '!' } + default: + return state + } +} + +// Notify store listeners on a later macrotask, like autoBatchEnhancer. +const deferNotifications: StoreEnhancer = (next) => (r, preloaded) => { + const store = next(r, preloaded) + const listeners = new Set<() => void>() + let scheduled = false + store.subscribe(() => { + if (scheduled) return + scheduled = true + setTimeout(() => { + scheduled = false + for (const l of listeners) l() + }, 0) + }) + return { + ...store, + subscribe(listener: () => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } +} + +const flushNotifications = () => + rtl.act(() => new Promise((r) => setTimeout(r, 5))) + +const selectValue = (s: State) => s.value + +function makeStore() { + return createStore(reducer, undefined, deferNotifications) +} + +// A hook that has already re-rendered from a store change is fully +// subscribed (in the signal implementation: promoted out of the coarse +// tier). The freshness question only matters once it's in that state. +async function warmUp(store: ReturnType) { + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + await flushNotifications() + expect(store.getState().value).toBe(1) +} + +describe('snapshot freshness between dispatch and notification', () => { + it('a re-render inside the gap reads the current store state', async () => { + const store = makeStore() + const seen: number[] = [] + let forceParent: () => void = () => {} + + function Child() { + const value = useSelector(selectValue) + seen.push(value) + return
{value}
+ } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + return + } + + rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('value').textContent).toBe('0') + await warmUp(store) + expect(rtl.screen.getByTestId('value').textContent).toBe('1') + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + expect(store.getState().value).toBe(2) + + rtl.act(() => { + forceParent() + }) + expect(rtl.screen.getByTestId('value').textContent).toBe('2') + + await flushNotifications() + expect(rtl.screen.getByTestId('value').textContent).toBe('2') + expect(seen.at(-1)).toBe(2) + }) + + it('stock and signal hooks in one tree never disagree within a render pass', async () => { + const store = makeStore() + const passes: Array<{ parent: number; child: number }> = [] + let current: { parent: number; child: number } | null = null + let forceParent: () => void = () => {} + + function Child() { + const child = useSelector(selectValue) + current = { parent: current?.parent ?? -1, child } + passes.push(current) + return null + } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + const parent = useStockSelector(selectValue) + current = { parent, child: -1 } + return + } + + rtl.render( + + + , + ) + + await warmUp(store) + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + rtl.act(() => { + forceParent() + }) + await flushNotifications() + + for (const p of passes) { + expect(p.child).toBe(p.parent) + } + expect(passes.at(-1)).toEqual({ parent: 2, child: 2 }) + }) + + it('a dispatch from a layout effect is visible to the post-commit snapshot check', async () => { + const store = makeStore() + const seen: number[] = [] + + function Comp({ fire }: { fire: boolean }) { + // Inline selector on purpose: a new selector identity each render + // is what makes React run its post-commit getSnapshot consistency + // check (RTK Query's hooks have this shape). + const value = useSelector((s: State) => s.value) + seen.push(value) + React.useLayoutEffect(() => { + if (fire) store.dispatch({ type: 'inc' }) + }, [fire]) + return null + } + + const { rerender } = rtl.render( + + + , + ) + await warmUp(store) + + rerender( + + + , + ) + // React re-checks getSnapshot after commit and re-renders + // synchronously if it moved; the value must not wait for the + // deferred notification. + expect(store.getState().value).toBe(2) + expect(seen.at(-1)).toBe(2) + + const before = seen.length + await flushNotifications() + expect(seen.length).toBe(before) + }) + + it('a value kept from an earlier evaluation reaches the component raw', async () => { + const store = makeStore() + const seen: Array = [] + let forceParent: () => void = () => {} + + // Holds on to an object from a previous run and returns it later, + // like RTK Query's `lastResult`. A value captured during a tracked + // evaluation is a proxy. + let kept: State['item'] | undefined + const selectKept = (s: State) => { + if (s.value < 2) kept = s.item + return kept + } + + function Child() { + const item = useSelector(selectKept) + seen.push(item) + return null + } + function Parent() { + const [, setN] = React.useState(0) + forceParent = () => setN((n) => n + 1) + return + } + + rtl.render( + + + , + ) + await warmUp(store) + + rtl.act(() => { + store.dispatch({ type: 'inc' }) + }) + rtl.act(() => { + forceParent() + }) + await flushNotifications() + + const raw = store.getState().item + for (const item of seen) { + expect(item).toBe(raw) + } + }) +}) diff --git a/test/signals/ssr.spec.tsx b/test/signals/ssr.spec.tsx new file mode 100644 index 000000000..2db16f8ab --- /dev/null +++ b/test/signals/ssr.spec.tsx @@ -0,0 +1,418 @@ +/** + * Server rendering and hydration parity between stock `useSelector` and + * `useSignalSelector`. + * + * Imports `Provider`/`useSelector` from their source modules rather than + * `src/index`, so these are unaffected by the local benchmark override + * in `src/exports.ts`. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import type { PayloadAction } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Provider from '../../src/components/Provider' +import { useSelector } from '../../src/hooks/useSelector' +import { useSignalContext } from '../../src/signals/context' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { SignalProvider } from '../../src/signals/SignalProvider' +import { unwrap } from '../../src/signals/trackingProxy' +import { useSignalSelector } from '../../src/signals/useSignalSelector' + +interface Todo { + id: number + text: string + done: boolean +} + +interface AppState { + count: number + todos: Todo[] + user: { name: string; prefs: { theme: string } } +} + +const initialState: AppState = { + count: 0, + todos: [ + { id: 1, text: 'first', done: false }, + { id: 2, text: 'second', done: true }, + ], + user: { name: 'mark', prefs: { theme: 'dark' } }, +} + +const appSlice = createSlice({ + name: 'app', + initialState, + reducers: { + increment(state) { + state.count += 1 + }, + setTheme(state, action: PayloadAction) { + state.user.prefs.theme = action.payload + }, + toggle(state, action: PayloadAction) { + const todo = state.todos.find((t) => t.id === action.payload) + if (todo) todo.done = !todo.done + }, + }, +}) + +const { increment, setTheme } = appSlice.actions + +function makeStore(preloadedState?: AppState) { + return configureStore({ + reducer: appSlice.reducer, + preloadedState, + middleware: (getDefault) => + getDefault({ immutableCheck: false, serializableCheck: false }), + }) +} + +const selectCount = (state: AppState) => state.count +const selectTheme = (state: AppState) => state.user.prefs.theme +const selectDoneCount = (state: AppState) => + state.todos.filter((t) => t.done).length +const selectTexts = (state: AppState) => + state.todos.map((t) => t.text).join(',') + +function Body({ + useHook, +}: { + useHook: (selector: (state: AppState) => R) => R +}) { + const count = useHook(selectCount) + const theme = useHook(selectTheme) + const done = useHook(selectDoneCount) + const texts = useHook(selectTexts) + return ( +
+ {count} + {theme} + {done} + {texts} +
+ ) +} + +describe('renderToString parity', () => { + it('produces identical markup to stock useSelector', () => { + const stockStore = makeStore() + const signalStore = makeStore() + + const stockMarkup = renderToString( + + + , + ) + const signalMarkup = renderToString( + + + , + ) + + expect(signalMarkup).toBe(stockMarkup) + }) + + it('reflects dispatches applied before rendering', () => { + const stockStore = makeStore() + const signalStore = makeStore() + for (const store of [stockStore, signalStore]) { + store.dispatch(increment()) + store.dispatch(increment()) + store.dispatch(setTheme('light')) + } + + const stockMarkup = renderToString( + + + , + ) + const signalMarkup = renderToString( + + + , + ) + + expect(signalMarkup).toBe(stockMarkup) + expect(signalMarkup).toContain('light') + expect(signalMarkup).toContain('2') + }) + + it('never leaks a tracking proxy into the selector result on the server', () => { + const store = makeStore() + const seen: unknown[] = [] + function Capture() { + const user = useSignalSelector((state: AppState) => state.user) + seen.push(user) + return
{user.name}
+ } + + renderToString( + + + , + ) + + expect(seen).toHaveLength(1) + expect(seen[0]).toBe(store.getState().user) + expect(unwrap(seen[0])).toBe(seen[0]) + }) + + it('registers nothing in the registry, because subscribe never runs', () => { + const store = makeStore() + let registry: PathSignalRegistry | undefined + + function Probe() { + registry = useSignalContext().registry + const count = useSignalSelector(selectCount) + const theme = useSignalSelector(selectTheme) + return ( +
+ {count} + {theme} +
+ ) + } + + const markup = renderToString( + + + , + ) + expect(markup).toContain('dark') + + expect(registry).toBeDefined() + expect(registry!.size()).toBe(0) + expect(registry!.segmentIndex.size()).toBe(0) + }) +}) + +describe('hydration', () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + afterEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + // React 18's server renderer warns that `useLayoutEffect` does nothing on + // the server. Stock `Provider` triggers it too — both providers subscribe + // through `useIsomorphicLayoutEffect`, which resolves to `useLayoutEffect` + // under jsdom. These tests are about hydration mismatches, so the spy is + // reset here to cover only the `hydrateRoot` call that follows. + function mountMarkup(markup: string) { + const rootDiv = document.createElement('div') + document.body.appendChild(rootDiv) + rootDiv.innerHTML = markup + consoleErrorSpy.mockClear() + return rootDiv + } + + it('hydrates cleanly when the client store matches the server state', async () => { + const ssrStore = makeStore() + ssrStore.dispatch(increment()) + const serverState = ssrStore.getState() + + const markup = renderToString( + + + , + ) + const rootDiv = mountMarkup(markup) + + const clientStore = makeStore(serverState) + await rtl.act(async () => { + hydrateRoot( + rootDiv, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + }) + + it('stays reactive after hydration', async () => { + const ssrStore = makeStore() + const serverState = ssrStore.getState() + const markup = renderToString( + + + , + ) + const rootDiv = mountMarkup(markup) + + const clientStore = makeStore(serverState) + await rtl.act(async () => { + hydrateRoot( + rootDiv, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + expect(consoleErrorSpy).not.toHaveBeenCalled() + + await rtl.act(async () => { + clientStore.dispatch(increment()) + clientStore.dispatch(setTheme('light')) + }) + + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + expect(rtl.screen.getByTestId('theme').textContent).toBe('light') + }) + + it('warns on mismatch when the client store diverged and no serverState is given', async () => { + const ssrStore = makeStore() + const serverState = ssrStore.getState() + const markup = renderToString( + + + , + ) + const rootDiv = mountMarkup(markup) + + const clientStore = makeStore(serverState) + clientStore.dispatch(increment()) + + await rtl.act(async () => { + hydrateRoot( + rootDiv, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('serverState suppresses the mismatch, matching stock Provider', async () => { + const ssrStore = makeStore() + const serverState = ssrStore.getState() + const markup = renderToString( + + + , + ) + + // Stock control: `` feeds `getServerSnapshot` + // and hydration is clean. + const stockRoot = mountMarkup(markup) + const stockClientStore = makeStore(serverState) + stockClientStore.dispatch(increment()) + await rtl.act(async () => { + hydrateRoot( + stockRoot, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + expect(consoleErrorSpy).not.toHaveBeenCalled() + vi.clearAllMocks() + document.body.innerHTML = '' + + const signalRoot = mountMarkup(markup) + const signalClientStore = makeStore(serverState) + signalClientStore.dispatch(increment()) + await rtl.act(async () => { + hydrateRoot( + signalRoot, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + expect(consoleErrorSpy).not.toHaveBeenCalled() + // Post-hydration render reads the client store, so the diverged + // value shows up. + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + }) + + it('stays reactive after a serverState hydration', async () => { + const ssrStore = makeStore() + const serverState = ssrStore.getState() + const markup = renderToString( + + + , + ) + const rootDiv = mountMarkup(markup) + + const clientStore = makeStore(serverState) + clientStore.dispatch(setTheme('light')) + await rtl.act(async () => { + hydrateRoot( + rootDiv, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(rtl.screen.getByTestId('theme').textContent).toBe('light') + + await rtl.act(async () => { + clientStore.dispatch(increment()) + clientStore.dispatch(setTheme('sepia')) + }) + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + expect(rtl.screen.getByTestId('theme').textContent).toBe('sepia') + }) + + it('mixed tree: stock and signal hooks hydrate together without mismatch', async () => { + const ssrStore = makeStore() + ssrStore.dispatch(setTheme('light')) + const serverState = ssrStore.getState() + + function Mixed() { + const stockCount = useSelector(selectCount) + const signalTheme = useSignalSelector(selectTheme) + return ( +
+ {stockCount} + {signalTheme} +
+ ) + } + + const markup = renderToString( + + + , + ) + const rootDiv = mountMarkup(markup) + + const clientStore = makeStore(serverState) + await rtl.act(async () => { + hydrateRoot( + rootDiv, + + + , + { onRecoverableError: (error) => console.error(error) }, + ) + }) + + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(rtl.screen.getByTestId('signal').textContent).toBe('light') + + await rtl.act(async () => { + clientStore.dispatch(increment()) + }) + expect(rtl.screen.getByTestId('stock').textContent).toBe('1') + }) +}) diff --git a/test/signals/storeSwap.spec.tsx b/test/signals/storeSwap.spec.tsx new file mode 100644 index 000000000..cc9eb649c --- /dev/null +++ b/test/signals/storeSwap.spec.tsx @@ -0,0 +1,171 @@ +/** + * Swapping the `store` prop on a mounted SignalProvider must behave like + * stock Provider: components immediately read from the new store, the old + * store is fully disconnected, and the signal graph starts over — the + * registry and the diff baseline are keyed to the store, so the first + * dispatch on the new store diffs new-store state against new-store + * state, never against the old store's. + */ +import { configureStore } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +interface RootState { + counter: { value: number } + other: { name: string } +} + +const makeStore = (initialValue = 0, name = 'stock') => + configureStore({ + reducer: { + counter: (state = { value: initialValue }, action: any) => + action.type === 'increment' ? { value: state.value + 1 } : state, + other: (state = { name }, action: any) => + action.type === 'rename' ? { name: action.payload } : state, + }, + }) + +afterEach(() => { + rtl.cleanup() +}) + +describe('SignalProvider store prop swap', () => { + it('reads from the new store after a swap and disconnects the old one', () => { + const storeA = makeStore(0) + const storeB = makeStore(100) + + let renderCount = 0 + let selectorCalls = 0 + + function Counter() { + renderCount++ + const value = useSignalSelector((s: RootState) => { + selectorCalls++ + return s.counter.value + }) + return
{value}
+ } + + const ui = (store: ReturnType) => ( + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + expect(getByTestId('value').textContent).toBe('0') + expect(renderCount).toBe(1) + + rtl.act(() => { + storeA.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(renderCount).toBe(2) + + rerender(ui(storeB)) + expect(getByTestId('value').textContent).toBe('100') + + const rendersAfterSwap = renderCount + + // The old store is disconnected: dispatching on it does nothing. + rtl.act(() => { + storeA.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('100') + expect(renderCount).toBe(rendersAfterSwap) + + // The new store is live. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('101') + expect(renderCount).toBe(rendersAfterSwap + 1) + }) + + it('selective execution still holds after a swap', () => { + const storeA = makeStore(0, 'a') + const storeB = makeStore(0, 'b') + + let nameRenders = 0 + + function Name() { + nameRenders++ + const name = useSignalSelector((s: RootState) => s.other.name) + return
{name}
+ } + function Counter() { + const value = useSignalSelector((s: RootState) => s.counter.value) + return
{value}
+ } + + const ui = (store: ReturnType) => ( + + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + expect(getByTestId('name').textContent).toBe('a') + + rerender(ui(storeB)) + expect(getByTestId('name').textContent).toBe('b') + expect(getByTestId('value').textContent).toBe('0') + + const nameRendersAfterSwap = nameRenders + + // A counter-only dispatch on the new store must not re-render Name: + // the fresh registry gates it on the `other` segment, not stale + // footprints from the old store's graph. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('value').textContent).toBe('1') + expect(nameRenders).toBe(nameRendersAfterSwap) + + rtl.act(() => { + storeB.dispatch({ type: 'rename', payload: 'c' }) + }) + expect(getByTestId('name').textContent).toBe('c') + expect(nameRenders).toBe(nameRendersAfterSwap + 1) + }) + + it('first dispatch after a swap diffs against the new store state, not the old', () => { + // Same shape, different values. If the provider kept the old diff + // baseline, the swap-then-dispatch diff would report `other` as + // changed (old 'a' vs new 'b') and wake the Name subscriber. + const storeA = makeStore(0, 'a') + const storeB = makeStore(50, 'b') + + let nameSelectorCalls = 0 + + function Name() { + const name = useSignalSelector((s: RootState) => { + nameSelectorCalls++ + return s.other.name + }) + return
{name}
+ } + + const ui = (store: ReturnType) => ( + + + + ) + + const { getByTestId, rerender } = rtl.render(ui(storeA)) + rerender(ui(storeB)) + expect(getByTestId('name').textContent).toBe('b') + + const callsAfterSwap = nameSelectorCalls + + // `other` did not change within storeB, so the selector must not + // re-run — a stale old-store baseline would make it re-run here. + rtl.act(() => { + storeB.dispatch({ type: 'increment' }) + }) + expect(getByTestId('name').textContent).toBe('b') + expect(nameSelectorCalls).toBe(callsAfterSwap) + }) +}) diff --git a/test/signals/suspenseUncommitted.spec.tsx b/test/signals/suspenseUncommitted.spec.tsx new file mode 100644 index 000000000..c9c449e23 --- /dev/null +++ b/test/signals/suspenseUncommitted.spec.tsx @@ -0,0 +1,505 @@ +/** + * Suspense, transitions, and uncommitted components. + * + * React only calls a `useSyncExternalStore` subscribe function for + * components that COMMIT. A component that renders but never commits — + * it suspended, or its render was thrown away when a transition was + * abandoned — never subscribes, so it never unsubscribes either. + * Whatever the hook created during render must therefore be safe to + * abandon: nothing render-phase may anchor itself in the provider-level + * `PathSignalRegistry`, because no cleanup path will ever run for it. + * + * Today that invariant does not hold for UNGATEABLE hooks (selectors the + * coarse probe can't gate: root enumeration, empty footprint, non-plain + * root state). The mount-seeding path calls `selectorComputed.get()` + * during render, which creates path signals in `registry.signals` and + * links the computed to them. If the component never commits, those + * entries stay in the registry until the state paths are pruned. + * + * These tests assert the DESIRED behavior: an uncommitted component + * leaves the registry exactly as it found it. The ungateable cases fail + * against the current implementation — that failure is the leak. + * + * Two of the failures go beyond the obvious suspend-forever case: + * - StrictMode double-invokes useMemo factories in dev, so a plain + * StrictMode mount creates TWO bridges, each of which eagerly builds; + * the discarded one is orphaned. Every ungateable mount leaks in dev. + * - A component that suspends and LATER commits still leaks: React + * retries with a fresh build after showing the fallback, and the + * first render attempt's bridge is orphaned. + * + * Modeled on mobx-react-lite's strictAndConcurrentMode tests + * (mobx-react-lite PRs #121 and #332), but where MobX must force GC and + * observe FinalizationRegistry callbacks, our leak is anchored in the + * registry Map, so `registry.debugStats()` observes it deterministically. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import React, { StrictMode, Suspense, startTransition, useState } from 'react' +import { afterEach, describe, expect, it } from 'vitest' +import type { RegistryStats } from '../../src/signals/pathSignalRegistry' +import type { PathSignalRegistry } from '../../src/signals' +import { + SignalProvider, + useSignalContext, + useSignalSelector, +} from '../../src/signals' + +const makeStore = () => { + const aSlice = createSlice({ + name: 'a', + initialState: { value: 0, nested: { n: 0 } }, + reducers: { + bumpA(state) { + state.value++ + state.nested.n += 10 + }, + }, + }) + const dynSlice = createSlice({ + name: 'dyn', + initialState: {} as Record, + reducers: { + addDyn(state, action: { payload: string }) { + state[action.payload] = { v: 1 } + }, + removeDyn(state, action: { payload: string }) { + delete state[action.payload] + }, + }, + }) + + const store = configureStore({ + reducer: { a: aSlice.reducer, dyn: dynSlice.reducer }, + }) + + return { + store, + bumpA: aSlice.actions.bumpA, + addDyn: dynSlice.actions.addDyn, + removeDyn: dynSlice.actions.removeDyn, + } +} + +type AppState = ReturnType['store']['getState']> + +const noStabilityCheck = { + devModeChecks: { stabilityCheck: 'never' as const }, +} + +// Enumerating the root makes the probe mark the hook ungateable, so the +// mount path builds the deep graph eagerly, during render. The nested +// read guarantees real path signals get created by that build. +const ungateableSelector = (s: AppState) => { + Object.keys(s) + return s.a.nested.n +} + +// Reads one root key without enumerating: stays in the coarse tier, +// creates no signals at mount. +const gateableSelector = (s: AppState) => s.a.nested.n + +const EMPTY_STATS: RegistryStats = { + signals: 0, + prefixCounts: 0, + prefixOnlyPaths: 0, + childIndex: 0, + arrayMetas: 0, + columnsByArray: 0, + structuresByArray: 0, + segmentSubs: 0, +} + +function makeRegistryCapture() { + const captured: { registry: PathSignalRegistry | null } = { registry: null } + function Capture() { + captured.registry = useSignalContext().registry + return null + } + const registry = () => { + if (captured.registry === null) throw new Error('registry not captured') + return captured.registry + } + return { registry, Capture } +} + +// A manually-controlled Suspense gate. +function makeGate() { + let resolve!: () => void + let resolved = false + const promise = new Promise((r) => { + resolve = r + }).then(() => { + resolved = true + }) + return { + isResolved: () => resolved, + suspendIfPending() { + if (!resolved) throw promise + }, + async open() { + resolve() + await promise + }, + } +} + +afterEach(() => { + rtl.cleanup() +}) + +describe('baseline: committed components already clean up', () => { + it('ungateable hook builds path signals at mount and releases them all on unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + + function Watcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + , + ) + + expect(getByTestId('n').textContent).toBe('0') + // Eager render-phase build: the deep graph exists before any dispatch. + expect(registry().debugStats().signals).toBeGreaterThan(0) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('component suspends before commit, promise never resolves', () => { + it('gateable hook: registers nothing while suspended and nothing after unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(gateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback').textContent).toBe('loading') + // The coarse sub is only registered inside subscribe, which never + // ran; the probe itself creates no signals. Nothing to leak. + expect(registry().debugStats()).toEqual(EMPTY_STATS) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('ungateable hook: leaves no path signals behind while suspended', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { getByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback').textContent).toBe('loading') + // The component rendered but never committed: subscribe never ran, + // no effect exists, and no cleanup path will ever fire for this + // hook. Anything it left in the registry is unreachable garbage. + expect(registry().debugStats().signals).toBe(0) + }) + + it('ungateable hook: registry is empty after the suspended tree unmounts', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount } = rtl.render( + + + loading}> + + + , + ) + + // Unmounting the whole tree cannot help: React never subscribed + // this hook, so it has no unsubscribe to call during unmount. + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('component suspends, then commits when the promise resolves', () => { + it('recovers fully: subscribes on commit, updates on dispatch, releases on unmount', async () => { + const { store, bumpA } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId, queryByTestId } = rtl.render( + + + loading}> + + + , + ) + + expect(getByTestId('fallback').textContent).toBe('loading') + + await rtl.act(async () => { + await gate.open() + }) + + // Committed: fallback gone, content up. + expect(queryByTestId('fallback')).toBeNull() + expect(getByTestId('n').textContent).toBe('0') + + // The subscription is live: dispatches flow through the deep graph. + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('n').textContent).toBe('10') + + // Commit gave the hook a real unsubscribe path; unmount uses it. + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe("today's only recovery path: pruning when state paths disappear", () => { + it("a never-committed hook's leaked leaf signal is removed when its state key is deleted", async () => { + // This test documents CURRENT behavior, not desired behavior: the + // leaked path signal sits in the registry until the state key it + // tracks is deleted, and prune() removes it as part of normal diff + // processing. It passes today and should keep passing after the + // fix (at which point there is simply nothing to prune). + const { store, addDyn, removeDyn } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + rtl.act(() => { + store.dispatch(addDyn('x')) + }) + + const readsDynX = (s: AppState) => { + Object.keys(s) + return s.dyn.x?.v ?? -1 + } + + function SuspendingWatcher() { + const v = useSignalSelector(readsDynX, noStabilityCheck) + gate.suspendIfPending() + return
{v}
+ } + + rtl.render( + + + loading}> + + + , + ) + + // Today the eager render-phase build creates the leaf signal even + // though the component never commits. Deleting the key must prune + // it — the registry must not keep entries for paths that no longer + // exist in state, regardless of who reads them. (Post-fix, nothing + // leaks in the first place and this holds trivially.) + rtl.act(() => { + store.dispatch(removeDyn('x')) + }) + + expect(registry().debugPaths()).not.toContain('dyn.x.v') + }) +}) + +describe('transitions', () => { + it('a suspended transition that never finishes leaves the registry empty after unmount', async () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + let setShowOuter: ((show: boolean) => void) | null = null + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + function App() { + const [show, setShow] = useState(false) + setShowOuter = setShow + return ( + + + loading}> + {show ? :
idle
} +
+
+ ) + } + + const { unmount, getByTestId } = rtl.render() + expect(getByTestId('idle').textContent).toBe('idle') + + // Mount the suspending component inside a transition. The transition + // render runs the hook (and, today, the eager deep build), then + // suspends; React holds the old UI, so nothing ever commits. + rtl.act(() => { + startTransition(() => { + setShowOuter!(true) + }) + }) + expect(getByTestId('idle').textContent).toBe('idle') + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('an abandoned transition render leaves the registry empty while the app keeps running', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + let setShowOuter: ((show: boolean) => void) | null = null + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + function App() { + const [show, setShow] = useState(false) + setShowOuter = setShow + return ( + + + loading}> + {show ? :
idle
} +
+
+ ) + } + + const { getByTestId } = rtl.render() + + // Start showing the suspending component in a transition, then + // change course before it resolves. React throws the transition + // render away; the component never commits, and the tree it was + // going to join is still mounted — so no unmount will ever come to + // the rescue. The registry must not have grown. + rtl.act(() => { + startTransition(() => { + setShowOuter!(true) + }) + }) + rtl.act(() => { + setShowOuter!(false) + }) + + expect(getByTestId('idle').textContent).toBe('idle') + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) + +describe('StrictMode', () => { + it('double-invoked render of an ungateable hook does not double-count, and unmount is clean', () => { + const { store, bumpA } = makeStore() + const { registry, Capture } = makeRegistryCapture() + + function Watcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + + + , + ) + + expect(getByTestId('n').textContent).toBe('0') + const mounted = registry().debugStats() + + // StrictMode double-invokes the useMemo factory in dev, so TWO + // bridges were created at mount and each ran the eager deep build; + // only one of them ever gets a subscribe/unsubscribe pair. The + // committed one must keep working; the discarded one must not + // leave anything in the registry (today it does — that is the + // failure this test pins). + rtl.act(() => { + store.dispatch(bumpA()) + }) + expect(getByTestId('n').textContent).toBe('10') + expect(registry().debugStats().segmentSubs).toBe(mounted.segmentSubs) + + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) + + it('StrictMode + suspended-forever ungateable hook leaves the registry empty after unmount', () => { + const { store } = makeStore() + const { registry, Capture } = makeRegistryCapture() + const gate = makeGate() + + function SuspendingWatcher() { + const n = useSignalSelector(ungateableSelector, noStabilityCheck) + gate.suspendIfPending() + return
{n}
+ } + + const { unmount, getByTestId } = rtl.render( + + + + loading}> + + + + , + ) + + expect(getByTestId('fallback').textContent).toBe('loading') + unmount() + expect(registry().debugStats()).toEqual(EMPTY_STATS) + }) +}) diff --git a/test/signals/trackingProxy.spec.ts b/test/signals/trackingProxy.spec.ts new file mode 100644 index 000000000..0b847bad6 --- /dev/null +++ b/test/signals/trackingProxy.spec.ts @@ -0,0 +1,2006 @@ +import { describe, it, expect, vi } from 'vitest' +import { + createLeafTracker, + createTrackingProxy, + finalizeDependencies, + getProxyPath, + unwrap, +} from '../../src/signals/trackingProxy' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { reconcileState } from '../../src/signals/diff' +import { alienEngine } from '../../src/signals/engine' +import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry' + +function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) +} + +describe('createTrackingProxy', () => { + // ─── Basic property access ─── + + it('returns primitive values from the underlying object', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.name).toBe('Alice') + expect(proxy.age).toBe(30) + }) + + it('creates signals for accessed primitive paths', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(registry.has('name')).toBe(false) + proxy.name + expect(registry.has('name')).toBe(true) + + expect(registry.has('age')).toBe(false) + proxy.age + expect(registry.has('age')).toBe(true) + }) + + it('does NOT create signals for unaccessed paths', () => { + const state = { name: 'Alice', age: 30, email: 'alice@example.com' } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy.name // access only name + expect(registry.has('name')).toBe(true) + expect(registry.has('age')).toBe(false) + expect(registry.has('email')).toBe(false) + }) + + // ─── Nested objects ─── + + it('returns a tracking proxy for nested objects (not raw value)', () => { + const state = { user: { name: 'Alice' } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const userProxy = proxy.user + // Should be a proxy, not the raw object + expect(userProxy).not.toBe(state.user) + // But should have the same values + expect(userProxy.name).toBe('Alice') + }) + + it('creates signals for nested property paths', () => { + const state = { user: { name: 'Alice', address: { city: 'NYC' } } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy.user.address.city + + // Intermediate objects use ensurePrefix (no signal), leaves use getOrCreate + expect(registry.hasPrefix('user')).toBe(true) + expect(registry.hasPrefix('user.address')).toBe(true) + expect(registry.has('user.address.city')).toBe(true) // leaf — has signal + // Did not access user.name + expect(registry.has('user.name')).toBe(false) + }) + + it('caches child proxies within same evaluation', () => { + const state = { user: { name: 'Alice' } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const first = proxy.user + const second = proxy.user + expect(first).toBe(second) // same proxy instance + }) + + // ─── Array support ─── + + it('returns tracking proxies for array elements', () => { + const state = { todos: [{ id: 1, text: 'Test' }] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const todo = proxy.todos[0] + expect(todo.text).toBe('Test') + expect(registry.hasPrefix('todos')).toBe(true) // intermediate + // Identity-based path: element has 'id' field, so uses {id:1} instead of 0 + expect(registry.hasPrefix('todos.{id:1}')).toBe(true) // intermediate (identity path) + expect(registry.has('todos.{id:1}.text')).toBe(true) // leaf (identity path) + }) + + it('tracks array length access', () => { + const state = { items: [1, 2, 3] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.items.length).toBe(3) + expect(registry.has('items.length')).toBe(true) + }) + + // ─── Frozen objects (ES Proxy invariant compliance) ─── + + it('works with Object.freeze() state', () => { + const state = Object.freeze({ + user: Object.freeze({ name: 'Alice', age: 30 }), + filter: 'all' as const, + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.filter).toBe('all') + expect(proxy.user.name).toBe('Alice') + expect(proxy.user.age).toBe(30) + expect(registry.has('filter')).toBe(true) + expect(registry.has('user.name')).toBe(true) + }) + + it('works with deeply frozen state (Immer-style)', () => { + const state = Object.freeze({ + todos: Object.freeze([ + Object.freeze({ id: 1, text: 'Test', completed: false }), + Object.freeze({ id: 2, text: 'Another', completed: true }), + ]), + counters: Object.freeze({ + counter1: Object.freeze({ value: 0 }), + }), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.todos[0].text).toBe('Test') + expect(proxy.todos[1].completed).toBe(true) + expect(proxy.counters.counter1.value).toBe(0) + }) + + // ─── ownKeys / iteration tracking ─── + + it('tracks ownKeys access via @@keys signal', () => { + const state = { a: 1, b: 2, c: 3 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + Object.keys(proxy) + expect(registry.has('@@keys')).toBe(true) + }) + + it('tracks nested ownKeys with correct path', () => { + const state = { user: { name: 'Alice', age: 30 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + Object.keys(proxy.user) + expect(registry.has('user.@@keys')).toBe(true) + }) + + it('supports for...in enumeration', () => { + const state = { a: 1, b: 2 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const keys: string[] = [] + for (const key in proxy) { + keys.push(key) + } + expect(keys).toEqual(['a', 'b']) + expect(registry.has('@@keys')).toBe(true) + }) + + it('supports Object.entries on proxy', () => { + const state = Object.freeze({ x: 10, y: 20 }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const entries = Object.entries(proxy) + expect(entries).toEqual([ + ['x', 10], + ['y', 20], + ]) + }) + + it('supports array .map() through proxy', () => { + const state = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1, name: 'A' }), + Object.freeze({ id: 2, name: 'B' }), + ]), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const names = proxy.items.map( + (item: { id: number; name: string }) => item.name, + ) + expect(names).toEqual(['A', 'B']) + }) + + it('supports array .filter() through proxy', () => { + const state = Object.freeze({ + items: Object.freeze([ + Object.freeze({ id: 1, active: true }), + Object.freeze({ id: 2, active: false }), + Object.freeze({ id: 3, active: true }), + ]), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const active = proxy.items.filter( + (item: { id: number; active: boolean }) => item.active, + ) + expect(active).toHaveLength(2) + }) + + // ─── Symbol properties ─── + + it('passes through symbol properties without creating signals', () => { + const sym = Symbol('test') + const state = { [sym]: 'secret', name: 'Alice' } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy[sym]).toBe('secret') + // Symbol access should not create any signals + expect(registry.size()).toBe(0) + }) + + it('supports Symbol.iterator for arrays', () => { + const state = Object.freeze({ items: Object.freeze([1, 2, 3]) }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const spread = [...proxy.items] + expect(spread).toEqual([1, 2, 3]) + }) + + // ─── has trap ─── + + it('tracks "in" operator checks', () => { + const state = { name: 'Alice', age: 30 } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect('name' in proxy).toBe(true) + expect('missing' in proxy).toBe(false) + + // Both checked paths should have signals + expect(registry.has('name')).toBe(true) + expect(registry.has('missing')).toBe(true) + }) + + // ─── Null handling ─── + + it('treats null values as primitives (not objects)', () => { + const state = { value: null as null | string } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.value).toBe(null) + expect(registry.has('value')).toBe(true) + }) + + // ─── Path segments (nested proxy creation) ─── + + it('builds correct path keys for deeply nested access', () => { + const state = { + a: { b: { c: { d: 42 } } }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(proxy.a.b.c.d).toBe(42) + expect(registry.hasPrefix('a')).toBe(true) // intermediate + expect(registry.hasPrefix('a.b')).toBe(true) // intermediate + expect(registry.hasPrefix('a.b.c')).toBe(true) // intermediate + expect(registry.has('a.b.c.d')).toBe(true) // leaf + }) + + // ─── Nesting path behavior ─── + + it('only creates signals along the accessed path, not siblings', () => { + const state = { + users: { + alice: { name: 'Alice', age: 30 }, + bob: { name: 'Bob', age: 25 }, + }, + settings: { theme: 'dark' }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Access only alice's name + proxy.users.alice.name + + expect(registry.hasPrefix('users')).toBe(true) // intermediate + expect(registry.hasPrefix('users.alice')).toBe(true) // intermediate + expect(registry.has('users.alice.name')).toBe(true) // leaf + // Siblings NOT accessed + expect(registry.has('users.alice.age')).toBe(false) + expect(registry.hasPrefix('users.bob')).toBe(false) + expect(registry.has('users.bob.name')).toBe(false) + expect(registry.hasPrefix('settings')).toBe(false) + expect(registry.has('settings.theme')).toBe(false) + }) + + it('creates leaf signals with actual values, intermediate objects are prefix-only', () => { + const state = { a: { b: { c: 42 } } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy.a.b.c + + // Intermediate objects (a, a.b) only have prefix registrations, no signals + expect(registry.has('a')).toBe(false) + expect(registry.has('a.b')).toBe(false) + expect(registry.hasPrefix('a')).toBe(true) + expect(registry.hasPrefix('a.b')).toBe(true) + + // Leaf has a signal with the actual value + expect(registry.has('a.b.c')).toBe(true) + const sigABC = registry.getOrCreate('a.b.c', 42) + expect(sigABC.get()).toBe(42) + }) + + it('handles mixed nested types: objects, arrays, and primitives', () => { + const state = Object.freeze({ + entities: Object.freeze({ + users: Object.freeze([ + Object.freeze({ id: 1, tags: Object.freeze(['admin', 'active']) }), + ]), + }), + count: 5, + }) + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Traverse: entities → users → [0] → tags → [0] + const tag = proxy.entities.users[0].tags[0] + expect(tag).toBe('admin') + + expect(registry.hasPrefix('entities')).toBe(true) // intermediate + expect(registry.hasPrefix('entities.users')).toBe(true) // intermediate + // Identity-based: users array has objects with 'id', uses {id:1} + expect(registry.hasPrefix('entities.users.{id:1}')).toBe(true) // intermediate (identity path) + expect(registry.hasPrefix('entities.users.{id:1}.tags')).toBe(true) // intermediate + // tags is a primitive array — coarse signal on the array itself + expect(registry.has('entities.users.{id:1}.tags')).toBe(true) + expect(registry.has('entities.users.{id:1}.tags.0')).toBe(false) + // Not accessed + expect(registry.has('entities.users.{id:1}.id')).toBe(false) + expect(registry.has('entities.users.{id:1}.tags.1')).toBe(false) + expect(registry.hasPrefix('count')).toBe(false) + }) + + it('handles multiple access paths creating independent signal trees', () => { + const state = { + a: { x: 1, y: 2 }, + b: { x: 10, y: 20 }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy.a.x + proxy.b.y + + // Each branch creates its own path signals + expect(registry.hasPrefix('a')).toBe(true) // intermediate + expect(registry.has('a.x')).toBe(true) // leaf + expect(registry.has('a.y')).toBe(false) // not accessed + expect(registry.hasPrefix('b')).toBe(true) // intermediate + expect(registry.has('b.y')).toBe(true) // leaf + expect(registry.has('b.x')).toBe(false) // not accessed + }) + + it('array index paths use numeric string keys', () => { + const state = { items: ['zero', 'one', 'two'] } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy.items[0] + proxy.items[2] + + // Primitive array elements use a single coarse signal on the array + // itself instead of per-index signals (mount cost optimization). + expect(registry.has('items')).toBe(true) + expect(registry.has('items.0')).toBe(false) + expect(registry.has('items.2')).toBe(false) + }) + + it('nested iteration creates @@keys at each level', () => { + const state = { + data: { + nested: { a: 1, b: 2 }, + }, + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Iterate top-level keys + Object.keys(proxy) + // Iterate nested keys + Object.keys(proxy.data.nested) + + expect(registry.has('@@keys')).toBe(true) + expect(registry.has('data.nested.@@keys')).toBe(true) + // data.@@keys was NOT iterated + expect(registry.has('data.@@keys')).toBe(false) + }) + + it('accessing the same nested object via different parent proxies shares signals', () => { + const state = { user: { profile: { name: 'Alice' } } } + const registry = makeRegistry() + + // Two separate root proxies but same registry + const proxy1 = createTrackingProxy(state, '', registry, registry.proxyCache) + const proxy2 = createTrackingProxy(state, '', registry, registry.proxyCache) + + proxy1.user.profile.name + proxy2.user.profile.name + + // Only the leaf creates a signal; user and user.profile are prefix-only + expect(registry.size()).toBe(1) // user.profile.name (leaf) + }) + + it('child proxies from different root proxies track same signal paths', () => { + const state = Object.freeze({ + counter: Object.freeze({ value: 0 }), + label: 'test', + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let comp1Calls = 0 + let comp2Calls = 0 + + // Two computeds that access the same path via different proxy instances + const c1 = scope.run(() => + alienEngine.computed(() => { + comp1Calls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.counter.value + }), + ) + const c2 = scope.run(() => + alienEngine.computed(() => { + comp2Calls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.counter.value + }), + ) + + c1.get() + c2.get() + expect(comp1Calls).toBe(1) + expect(comp2Calls).toBe(1) + + // Update counter.value → BOTH computeds should re-evaluate + registry.update('counter.value', 1) + c1.get() + c2.get() + expect(comp1Calls).toBe(2) + expect(comp2Calls).toBe(2) + + // Update label → NEITHER should re-evaluate (not tracked) + registry.update('label', 'changed') + c1.get() + c2.get() + expect(comp1Calls).toBe(2) + expect(comp2Calls).toBe(2) + + scope.stop() + }) + + it('version counter bumps propagate to computeds returning objects (with explicit terminal dep)', () => { + const state = Object.freeze({ + nested: Object.freeze({ deep: Object.freeze({ value: 42 }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let calls = 0 + const c = scope.run(() => + alienEngine.computed(() => { + calls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const result = proxy.nested.deep + // Simulate what useSignalSelector does: detect proxy result + // and explicitly read the object's signal for terminal dependency + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + return result + }), + ) + + c.get() + expect(calls).toBe(1) + + // Bumping the version counter for 'nested.deep' should trigger re-eval + registry.update('nested.deep', {}) + c.get() + expect(calls).toBe(2) + + // Bumping a child signal should NOT trigger (we read 'nested.deep', not 'nested.deep.value') + registry.update('nested.deep.value', 99) + c.get() + expect(calls).toBe(2) + + scope.stop() + }) + + it('intermediate object traversal does NOT create signal dependency', () => { + const state = Object.freeze({ + nested: Object.freeze({ deep: Object.freeze({ value: 42 }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let calls = 0 + const c = scope.run(() => + alienEngine.computed(() => { + calls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + // Read through intermediate objects to a leaf + return proxy.nested.deep.value + }), + ) + + c.get() + expect(calls).toBe(1) + + // Bumping intermediate 'nested' should NOT trigger (no .get() was called on it) + registry.update('nested', {}) + c.get() + expect(calls).toBe(1) + + // Bumping the leaf signal SHOULD trigger + registry.update('nested.deep.value', 99) + c.get() + expect(calls).toBe(2) + + scope.stop() + }) + + it('parent object signal does NOT fire when only a child leaf changes', () => { + const state = Object.freeze({ + parent: Object.freeze({ child: Object.freeze({ leaf: 'hello' }) }), + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let parentCalls = 0 + let leafCalls = 0 + + // One computed reads the parent object + const parentComp = scope.run(() => + alienEngine.computed(() => { + parentCalls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.parent + }), + ) + + // Another computed reads the leaf + const leafComp = scope.run(() => + alienEngine.computed(() => { + leafCalls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.parent.child.leaf + }), + ) + + parentComp.get() + leafComp.get() + expect(parentCalls).toBe(1) + expect(leafCalls).toBe(1) + + // Update only the leaf signal — parent signal untouched + registry.update('parent.child.leaf', 'world') + parentComp.get() + leafComp.get() + expect(parentCalls).toBe(1) // NOT re-evaluated + expect(leafCalls).toBe(2) // re-evaluated + + scope.stop() + }) + + it('handles pathSegments offset for pre-scoped proxies', () => { + const nested = { x: 1, y: 2 } + const registry = makeRegistry() + // Create proxy with a base path — simulates a proxy for a nested subtree + const proxy = createTrackingProxy( + nested, + 'root.sub', + registry, + registry.proxyCache, + ) + + proxy.x + + expect(registry.has('root.sub.x')).toBe(true) + expect(registry.has('x')).toBe(false) // NOT at root level + }) + + // ─── Reactive integration ─── + + it('establishes signal dependencies for computed tracking', () => { + const state = Object.freeze({ + todos: Object.freeze([Object.freeze({ id: 1, text: 'Test' })]), + filter: 'all' as const, + }) + const registry = makeRegistry() + + // Simulate what a computed selector would do + const scope = alienEngine.createScope() + let callCount = 0 + + const c = scope.run(() => { + return alienEngine.computed(() => { + callCount++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.filter + }) + }) + + // Initial evaluation + expect(c.get()).toBe('all') + expect(callCount).toBe(1) + + // Update an unrelated signal → computed should NOT re-evaluate + // (only 'filter' signal was read, not 'todos') + if (registry.has('todos')) { + registry.update('todos', state.todos) + } + expect(c.get()).toBe('all') + expect(callCount).toBe(1) + + // Update the 'filter' signal → computed SHOULD re-evaluate on next read + registry.update('filter', 'completed') + c.get() // pull to trigger re-evaluation + expect(callCount).toBe(2) + + scope.stop() + }) + + it('tracks different dependencies for different computeds', () => { + const state = Object.freeze({ + todos: Object.freeze([Object.freeze({ id: 1, text: 'Test' })]), + counter: 0, + }) + const registry = makeRegistry() + const scope = alienEngine.createScope() + + let todoCalls = 0 + let counterCalls = 0 + + const todoComputed = scope.run(() => + alienEngine.computed(() => { + todoCalls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const result = proxy.todos + // Simulate terminal dependency for object result + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + return result + }), + ) + + const counterComputed = scope.run(() => + alienEngine.computed(() => { + counterCalls++ + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.counter + }), + ) + + // Initial evaluation + todoComputed.get() + counterComputed.get() + expect(todoCalls).toBe(1) + expect(counterCalls).toBe(1) + + // Update counter → only counterComputed should re-evaluate + alienEngine.batch(() => { + registry.update('counter', 1) + }) + counterComputed.get() + todoComputed.get() + expect(counterCalls).toBe(2) + expect(todoCalls).toBe(1) // NOT called again + + // Update todos → only todoComputed should re-evaluate + alienEngine.batch(() => { + registry.update('todos', []) + }) + todoComputed.get() + counterComputed.get() + expect(todoCalls).toBe(2) + expect(counterCalls).toBe(2) // NOT called again + + scope.stop() + }) +}) + +describe('unwrap', () => { + function makeRegistry(): PathSignalRegistry { + return createPathSignalRegistry(alienEngine) + } + + it('returns the raw target from a tracking proxy', () => { + const state = { name: 'Alice', nested: { x: 1 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + expect(unwrap(proxy)).toBe(state) + }) + + it('returns nested raw target from a child proxy', () => { + const state = { nested: { x: 1 } } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const childProxy = proxy.nested + expect(childProxy).not.toBe(state.nested) // is a proxy + expect(unwrap(childProxy)).toBe(state.nested) // unwraps to raw + }) + + it('returns the value unchanged for non-proxy objects', () => { + const obj = { a: 1 } + expect(unwrap(obj)).toBe(obj) + }) + + it('returns primitives unchanged', () => { + expect(unwrap(42)).toBe(42) + expect(unwrap('hello')).toBe('hello') + expect(unwrap(true)).toBe(true) + expect(unwrap(null)).toBe(null) + expect(unwrap(undefined)).toBe(undefined) + }) + + it('enables identity comparison between unwrapped proxy and raw value', () => { + const item = { id: 1, name: 'first' } + const state = { items: [item], current: item } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Without unwrap: proxy !== raw + const proxiedCurrent = proxy.current + expect(proxiedCurrent === item).toBe(false) + + // With unwrap: raw === raw + expect(unwrap(proxiedCurrent) === item).toBe(true) + }) + + it('indexOf/includes auto-unwrap proxy arguments', () => { + const state = { + items: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ], + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Access an item through the proxy — get back a proxy wrapper + const proxiedItem = proxy.items[1] + expect(proxiedItem).not.toBe(state.items[1]) // it's a proxy + + // indexOf/includes auto-unwrap proxy args internally, so these just work + expect(proxy.items.indexOf(proxiedItem)).toBe(1) + expect(proxy.items.includes(proxiedItem)).toBe(true) + + // Also works with manually unwrapped value + expect(proxy.items.indexOf(unwrap(proxiedItem))).toBe(1) + }) + + it('find() callback needs unwrap for identity comparison', () => { + const state = { + items: [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + { id: 3, name: 'c' }, + ], + } + const registry = makeRegistry() + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + // Get a proxy-wrapped item + const proxiedItem = proxy.items[1] + const rawItem = unwrap(proxiedItem) + + // find callback receives a scan recorder proxy; unwrap it for + // identity comparison against raw state values + const found = proxy.items.find( + (item: { id: number; name: string }) => unwrap(item) === rawItem, + ) + + // find() returns the raw element with an identity dependency; reads on + // it are not tracked individually + expect(found).toBe(state.items[1]) + expect(found!.name).toBe('b') + expect(registry.has('items.{id:2}')).toBe(true) + expect(registry.has('items.{id:2}.name')).toBe(false) + }) +}) + +// ─── Repeated-read memo ─── +// Within one evaluation, re-reading the same primitive prop on the same +// proxy returns the memoized value without touching the registry. The memo +// must NOT survive into the next evaluation, or the dependency would be +// silently dropped when alien-signals re-records deps. + +describe('repeated-read memo', () => { + function mountComputed(registry: PathSignalRegistry, body: () => T) { + const scope = alienEngine.createScope() + let evals = 0 + const c = scope.run(() => + alienEngine.computed(() => { + evals++ + return body() + }), + ) + return { c, scope, getEvals: () => evals } + } + + it('serves repeated reads of a primitive from the memo without extra registry lookups', () => { + const state = Object.freeze({ user: Object.freeze({ id: 7 }) }) + const registry = makeRegistry() + const getOrCreate = vi.spyOn(registry, 'getOrCreate') + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + + const user = proxy.user + const before = getOrCreate.mock.calls.length + for (let i = 0; i < 10; i++) { + expect(user.id).toBe(7) + } + // One lookup for the first read, none for the nine memo hits + expect(getOrCreate.mock.calls.length).toBe(before + 1) + }) + + it('re-links the dependency on the next evaluation even when the read is repeated', () => { + let state = { user: { id: 7 }, other: 0 } + const registry = makeRegistry() + const { c, scope, getEvals } = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + // Read `user.id` twice: the second read hits the memo in this evaluation + return proxy.user.id + proxy.user.id + proxy.other + }) + + expect(c.get()).toBe(14) + expect(getEvals()).toBe(1) + + // Change only `other`: the computed re-runs and reuses the same `user` + // child proxy (cached by target identity). alien-signals re-records + // deps on every run, so if the memo outlived the first evaluation the + // `user.id` read would be served without touching its signal and the + // dependency would be dropped here. + let next = { ...state, other: 1 } + reconcileState(state, next, registry, alienEngine) + state = next + expect(c.get()).toBe(15) + expect(getEvals()).toBe(2) + + // A `user.id` change must still be observed + next = { ...state, user: { id: 8 } } + reconcileState(state, next, registry, alienEngine) + state = next + expect(c.get()).toBe(17) + expect(getEvals()).toBe(3) + + scope.stop() + }) + + it('does not leak a memoized read across two computeds sharing a cached child proxy', () => { + let state = { user: { id: 7, name: 'a' } } + const registry = makeRegistry() + const a = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + return proxy.user.id + }) + const b = mountComputed(registry, () => { + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + // Same cached `user` proxy, same prop, different evaluation: B must + // register its own dependency rather than reuse A's memo hit + return proxy.user.id + }) + + expect(a.c.get()).toBe(7) + expect(b.c.get()).toBe(7) + + const next = { user: { id: 8, name: 'a' } } + reconcileState(state, next, registry, alienEngine) + state = next + + expect(a.c.get()).toBe(8) + expect(b.c.get()).toBe(8) + expect(a.getEvals()).toBe(2) + expect(b.getEvals()).toBe(2) + + a.scope.stop() + b.scope.stop() + }) + + it('warns once per path in dev when a primitive is re-read 100+ times in one evaluation', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const state = Object.freeze({ + post: Object.freeze({ id: 3 }), + comments: Object.freeze( + Array.from({ length: 150 }, (_, i) => + Object.freeze({ postId: i % 5 }), + ), + ), + }) + const registry = makeRegistry() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + + // Loop shape from a Reselect result function: `post` is a proxied + // input, `comments` is raw + const post = proxy.post + const count = state.comments.filter((c) => c.postId === post.id).length + expect(count).toBe(30) + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0][0]).toMatch(/read 'post\.id' 100\+ times/) + + // Same path again in a fresh evaluation: no second warning + const proxy2 = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const post2 = proxy2.post + state.comments.filter((c) => c.postId === post2.id) + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + } + }) + + it('does not warn for reads below the threshold or for alternating props', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const state = Object.freeze({ user: Object.freeze({ id: 1, role: 'x' }) }) + const registry = makeRegistry() + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) + const user = proxy.user + + for (let i = 0; i < 99; i++) user.id + for (let i = 0; i < 200; i++) { + user.id + user.role + } + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) +}) + +// ─── Array method dependency tracking ─── +// These tests verify which signals are registered when using array methods +// through the tracking proxy, and whether state changes trigger re-evaluation. +// The pattern simulates what useSignalSelector does: computed → proxy → selector → diff → re-eval. + +describe('array method dependency tracking', () => { + type TodoItem = { id: number; text: string; done: boolean } + type TodoState = { items: TodoItem[] } + + /** + * Create a frozen state. For structural sharing, pass existing items unchanged. + * @param items - Array of todo items + * @returns Deeply frozen TodoState + */ + function makeFrozenState(items: TodoItem[]): TodoState { + return Object.freeze({ + items: Object.freeze(items.map((i) => Object.freeze(i))), + }) as TodoState + } + + /** + * Simulate Immer-style structural sharing: only replace the item that changed. + * @param state - Current state + * @param id - ID of item to update + * @param patch - Partial fields to merge + * @returns New frozen state with structural sharing + */ + function updateItem( + state: TodoState, + id: number, + patch: Partial, + ): TodoState { + return Object.freeze({ + items: Object.freeze( + state.items.map((item) => + item.id === id ? Object.freeze({ ...item, ...patch }) : item, + ), + ), + }) as TodoState + } + + /** + * Simulate what useSignalSelector does: run selector in a computed with leaf tracking. + * @param getState - Returns current state + * @param selector - Selector function to run through tracking proxy + * @param registry - Signal registry for dependency tracking + * @returns Object with computed, call count getter, and stop function + */ + function createSelectorComputed( + getState: () => S, + selector: (state: S) => R, + registry: PathSignalRegistry, + ) { + let callCount = 0 + const scope = alienEngine.createScope() + + const computed = scope.run(() => + alienEngine.computed(() => { + callCount++ + const state = getState() + + const leafTracker = createLeafTracker() + + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + leafTracker, + ) + const result = selector(proxy as S) + + // Terminal object dependency (same as useSignalSelector) + const proxyPath = getProxyPath(result) + if (proxyPath !== undefined) { + registry.getOrCreate(proxyPath, result).get() + } + + // Deferred enumeration + leaf object tracking (same as useSignalSelector) + finalizeDependencies(leafTracker, registry) + + return result + }), + ) + + return { + computed, + getCallCount: () => callCount, + stop: () => scope.stop(), + } + } + + // ─── find() with property predicate ─── + + it('find(x => x.done) — re-runs when matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Change the matched element's text — should re-run (structural sharing) + const prevState = state + state = updateItem(state, 2, { text: 'b-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b-updated') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find(x => x.done) — SHOULD re-run when a non-matching element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Item 1 becomes done — it should now be the first match. + // Use structural sharing: only item 1 is a new object. + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a') // should be 'a' now + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find(x => x.done) — SHOULD re-run when a new element is added', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text ?? 'none', + registry, + ) + + expect(c.get()).toBe('none') + expect(getCallCount()).toBe(1) + + // Add a done item + const prevState = state + state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, // new, done + ]) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── filter() with property predicate ─── + + it('filter(x => x.done) — re-runs when matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + // Change text of matched element (structural sharing) + const prevState = state + state = updateItem(state, 1, { text: 'a-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a-updated,c') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter(x => x.done) — SHOULD re-run when a non-matching element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + // Item 2 becomes done (structural sharing — only item 2 changes) + const prevState = state + state = updateItem(state, 2, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b,c') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── some() / every() with property predicate ─── + + it('some(x => x.done) — SHOULD re-run when result changes from true to false', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Item 2 becomes not done (structural sharing) + const prevState = state + state = updateItem(state, 2, { done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('every(x => x.done) — SHOULD re-run when one element becomes not done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Item 1 becomes not done (structural sharing) + const prevState = state + state = updateItem(state, 1, { done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── map() (NOT overridden — should track all element properties) ─── + + it('map(x => x.text) — tracks all elements, re-runs when any element text changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.map((x) => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,b,c') + expect(getCallCount()).toBe(1) + + // Change item 3's text (structural sharing) + const prevState = state + state = updateItem(state, 3, { text: 'c-updated' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b,c-updated') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('map(x => x.text) — does NOT re-run when unrelated property (done) changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.map((x) => x.text).join(','), + registry, + ) + + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(1) + + // Change done but not text (structural sharing) + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + // Only `text` was read via the column signal, so changing `done` fires + // no tracked dependency and the selector does not re-run at all. + expect(getCallCount()).toBe(1) + + stop() + }) + + // ─── includes/indexOf — identity methods ─── + + it('includes() — SHOULD re-run when array structure changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + const targetItem = state.items[0] // raw ref + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.includes(targetItem as TodoItem), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Remove item 1 from array (structural sharing: item 2 is same ref) + const prevState = state + state = Object.freeze({ + items: Object.freeze([Object.freeze({ id: 2, text: 'b', done: false })]), + }) as TodoState + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── slice — structural ─── + + it('slice(0, 2) — SHOULD re-run when array gains elements', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => + s.items + .slice(0, 2) + .map((x) => x.text) + .join(','), + registry, + ) + + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(1) + + // Add element + const prevState = state + state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: false }, + ]) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── findIndex — predicate, returns primitive ─── + + it('findIndex(x => x.done) — SHOULD re-run when a different element becomes done', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.findIndex((x) => x.done), + registry, + ) + + expect(c.get()).toBe(1) + expect(getCallCount()).toBe(1) + + // Item 1 becomes done (structural sharing — item 2 unchanged) + const prevState = state + state = updateItem(state, 1, { done: true }) + reconcileState(prevState, state, registry, alienEngine) + // findIndex should now return 0 since item 1 is now done (first match) + expect(c.get()).toBe(0) + expect(getCallCount()).toBe(2) + + stop() + }) + + // ─── Structural signal matrix (append / insertOrReorder / remove) ─── + // Each scan method subscribes to a specific subset of structural signals + // depending on whether it matched. These tests verify both the "should + // re-run" and "should NOT re-run" cells of the matrix. + + describe('structural signal matrix', () => { + /** + * Append an item with structural sharing (existing item refs reused). + * @param state - Current state + * @param item - New item to append + * @returns New frozen state + */ + function appendItem(state: TodoState, item: TodoItem): TodoState { + return Object.freeze({ + items: Object.freeze([...state.items, Object.freeze(item)]), + }) as TodoState + } + + /** + * Remove an item by id with structural sharing. + * @param state - Current state + * @param id - ID of item to remove + * @returns New frozen state + */ + function removeItem(state: TodoState, id: number): TodoState { + return Object.freeze({ + items: Object.freeze(state.items.filter((i) => i.id !== id)), + }) as TodoState + } + + /** + * Insert an item at an index with structural sharing. + * @param state - Current state + * @param index - Position to insert at + * @param item - New item + * @returns New frozen state + */ + function insertItem( + state: TodoState, + index: number, + item: TodoItem, + ): TodoState { + const items = [...state.items] + items.splice(index, 0, Object.freeze(item) as TodoItem) + return Object.freeze({ items: Object.freeze(items) }) as TodoState + } + + it('find (matched) — does NOT re-run on append', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Append can never change the FIRST match — no re-run + const prevState = state + state = appendItem(state, { id: 3, text: 'c', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('find (matched) — does NOT re-run when a later element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Removing a later (never-matching, never-returned) element can't + // change the first match — no re-run + const prevState = state + state = removeItem(state, 3) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('find (matched) — SHOULD re-run when a matching element is inserted before the match', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('c') + expect(getCallCount()).toBe(1) + + // Insert a done item BEFORE the current match — new first match + const prevState = state + state = insertItem(state, 1, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find (matched) — does NOT re-run when an untracked prop of a non-matched element changes', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text, + registry, + ) + + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + // Only `done` is a tracked column; `text` of a non-matched element + // isn't read anywhere — no re-run + const prevState = state + state = updateItem(state, 1, { text: 'a-changed' }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(1) + + stop() + }) + + it('findIndex (matched) — SHOULD re-run when an earlier element is removed (index shifts)', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + { id: 3, text: 'c', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.findIndex((x) => x.done), + registry, + ) + + expect(c.get()).toBe(1) + expect(getCallCount()).toBe(1) + + // Removing an earlier element shifts the matched index from 1 → 0 + const prevState = state + state = removeItem(state, 1) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(0) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('some (true) — SHOULD re-run when the witness element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: false }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Removing the only done element flips some() to false + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('some (true) — does NOT re-run on append', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.some((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Appends can never flip some() from true to false — no re-run + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + stop() + }) + + it('every (true) — SHOULD re-run on append (new element might fail predicate)', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: false }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('every (true) — does NOT re-run when an element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every((x) => x.done), + registry, + ) + + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + // Removing an element can't flip every() from true to false — no re-run + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(1) + + stop() + }) + + it('every (false) — SHOULD re-run when the counterexample is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.every((x) => x.done), + registry, + ) + + expect(c.get()).toBe(false) + expect(getCallCount()).toBe(1) + + // Removing the only not-done element flips every() to true + const prevState = state + state = removeItem(state, 2) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(true) + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter — SHOULD re-run when a matching element is removed', () => { + let state = makeFrozenState([ + { id: 1, text: 'a', done: true }, + { id: 2, text: 'b', done: false }, + { id: 3, text: 'c', done: true }, + ]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), + registry, + ) + + expect(c.get()).toBe('a,c') + expect(getCallCount()).toBe(1) + + const prevState = state + state = removeItem(state, 3) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('filter — SHOULD re-run on append', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: true }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => + s.items + .filter((x) => x.done) + .map((x) => x.text) + .join(','), + registry, + ) + + expect(c.get()).toBe('a') + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('a,b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('find (missed) — SHOULD re-run on append', () => { + let state = makeFrozenState([{ id: 1, text: 'a', done: false }]) + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: TodoState) => s.items.find((x) => x.done)?.text ?? 'none', + registry, + ) + + expect(c.get()).toBe('none') + expect(getCallCount()).toBe(1) + + const prevState = state + state = appendItem(state, { id: 2, text: 'b', done: true }) + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe('b') + expect(getCallCount()).toBe(2) + + stop() + }) + + it('primitive elements fall back to coarse array signal (any change re-runs)', () => { + type NumState = { nums: number[] } + let state = Object.freeze({ + nums: Object.freeze([1, 2, 3]), + }) as NumState + const registry = makeRegistry() + + const { + computed: c, + getCallCount, + stop, + } = createSelectorComputed( + () => state, + (s: NumState) => s.nums.find((x) => x > 2), + registry, + ) + + expect(c.get()).toBe(3) + expect(getCallCount()).toBe(1) + + // Primitive scans use the coarse array signal — any array change re-runs + const prevState = state + state = Object.freeze({ nums: Object.freeze([1, 2, 3, 4]) }) as NumState + reconcileState(prevState, state, registry, alienEngine) + expect(c.get()).toBe(3) + expect(getCallCount()).toBe(2) + + stop() + }) + }) +}) diff --git a/test/signals/untrack.spec.tsx b/test/signals/untrack.spec.tsx new file mode 100644 index 000000000..11307a878 --- /dev/null +++ b/test/signals/untrack.spec.tsx @@ -0,0 +1,338 @@ +/** + * Tests for untrackResult — stripping tracking proxies from selector + * results at the hook boundary. + * + * Tracking proxies are evaluation-scoped tools. Components, effects, + * refs, DevTools, and dispatch payloads should only ever see raw + * (Immer-frozen) state. These tests pin that boundary. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it, afterEach } from 'vitest' + +import { SignalProvider } from '../../src/signals/SignalProvider' +import { useSignalSelector } from '../../src/signals/useSignalSelector' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { + createTrackingProxy, + getProxyPath, +} from '../../src/signals/trackingProxy' +import { untrackResult } from '../../src/signals/untrack' + +const isTrackingProxy = (v: unknown) => + v !== null && typeof v === 'object' && getProxyPath(v) !== undefined + +afterEach(() => { + rtl.cleanup() +}) + +interface Todo { + id: number + text: string + done: boolean +} + +function makeStore() { + const todosSlice = createSlice({ + name: 'todos', + initialState: { + items: [ + { id: 1, text: 'one', done: false }, + { id: 2, text: 'two', done: true }, + ] as Todo[], + meta: { count: 2 }, + }, + reducers: { + toggle(state, action: { payload: number }) { + const t = state.items.find((t) => t.id === action.payload) + if (t) t.done = !t.done + }, + replaceAll(state, action: { payload: Todo[] }) { + state.items = action.payload + }, + }, + }) + const store = configureStore({ reducer: { todos: todosSlice.reducer } }) + return { store, actions: todosSlice.actions } +} + +type AppState = ReturnType['store']['getState']> + +function renderWithCapture( + store: ReturnType['store'], + selector: (s: AppState) => R, +) { + const captured: R[] = [] + function Comp() { + const value = useSignalSelector(selector) + captured.push(value) + return null + } + rtl.render( + + + , + ) + return captured +} + +describe('untrack at the hook boundary', () => { + it('a selector returning a state object yields the raw object, === to store state', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items[0]) + + expect(captured.length).toBeGreaterThan(0) + const result = captured[captured.length - 1] + expect(isTrackingProxy(result)).toBe(false) + expect(result).toBe(store.getState().todos.items[0]) + }) + + it('a derived container has all proxy values swapped for raw state, in place', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => ({ + first: s.todos.items[0], + all: s.todos.items, + nested: { meta: s.todos.meta }, + count: s.todos.meta.count, + })) + + const result = captured[captured.length - 1] + expect(isTrackingProxy(result.first)).toBe(false) + expect(isTrackingProxy(result.all)).toBe(false) + expect(isTrackingProxy(result.nested.meta)).toBe(false) + expect(result.first).toBe(store.getState().todos.items[0]) + expect(result.all).toBe(store.getState().todos.items) + expect(result.nested.meta).toBe(store.getState().todos.meta) + }) + + it('derived arrays (filter/slice results) contain raw elements', () => { + const { store } = makeStore() + const captured = renderWithCapture(store, (s) => + s.todos.items.filter((t) => t.done), + ) + + const result = captured[captured.length - 1] + expect(result).toHaveLength(1) + expect(isTrackingProxy(result[0])).toBe(false) + expect(result[0]).toBe(store.getState().todos.items[1]) + }) + + it('re-dispatching a selected value stores raw state, not proxies', () => { + const { store, actions } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items) + + const selected = captured[captured.length - 1] + // Simulate the footgun: spread selected data back into an action + rtl.act(() => { + store.dispatch(actions.replaceAll(selected.map((t) => ({ ...t })))) + }) + + for (const item of store.getState().todos.items) { + expect(isTrackingProxy(item)).toBe(false) + for (const v of Object.values(item)) { + expect(isTrackingProxy(v)).toBe(false) + } + } + }) + + it('results stay raw across store-driven re-evaluations', () => { + const { store, actions } = makeStore() + const captured = renderWithCapture(store, (s) => s.todos.items[0]) + + rtl.act(() => { + store.dispatch(actions.toggle(1)) + }) + + const result = captured[captured.length - 1] + expect(isTrackingProxy(result)).toBe(false) + expect(result).toBe(store.getState().todos.items[0]) + expect(result.done).toBe(true) + }) +}) + +describe('untrackResult unit behavior', () => { + function setup() { + const registry = createPathSignalRegistry(alienEngine) + const state = { + a: { x: 1 }, + list: [{ id: 1, v: 'one' }], + } + Object.freeze(state.a) + Object.freeze(state.list[0]) + Object.freeze(state.list) + Object.freeze(state) + const proxy = createTrackingProxy(state, '', registry, registry.proxyCache) + return { state, proxy } + } + + it('returns primitives unchanged', () => { + expect(untrackResult(42)).toBe(42) + expect(untrackResult('x')).toBe('x') + expect(untrackResult(null)).toBe(null) + expect(untrackResult(undefined)).toBe(undefined) + }) + + it('swaps a bare proxy for its target', () => { + const { state, proxy } = setup() + expect(untrackResult(proxy.a)).toBe(state.a) + }) + + it('mutates derived containers in place, preserving container identity', () => { + const { state, proxy } = setup() + const container = { child: proxy.a, arr: [proxy.list[0]] } + const result = untrackResult(container) + expect(result).toBe(container) + expect(result.child).toBe(state.a) + expect(result.arr[0]).toBe(state.list[0]) + }) + + it('is idempotent (second pass over a memoized container is a no-op)', () => { + const { state, proxy } = setup() + const container = { child: proxy.a } + const once = untrackResult(container) + const twice = untrackResult(once) + expect(twice).toBe(container) + expect(twice.child).toBe(state.a) + }) + + it('handles Map values', () => { + const { state, proxy } = setup() + const m = new Map([['a', proxy.a]]) + const result = untrackResult(m) + expect(result).toBe(m) + expect(result.get('a')).toBe(state.a) + }) + + it('handles Set values', () => { + const { state, proxy } = setup() + const s = new Set([proxy.a]) + const result = untrackResult(s) + expect(result).toBe(s) + expect(result.has(state.a)).toBe(true) + expect(result.size).toBe(1) + }) + + it('handles self-referential containers without looping', () => { + const { state, proxy } = setup() + interface Cyclic { + child: object + self?: Cyclic + } + const container: Cyclic = { child: proxy.a } + container.self = container + const result = untrackResult(container) + expect(result.child).toBe(state.a) + expect(result.self).toBe(container) + }) + + it('documented limitation: frozen derived containers holding proxies are skipped', () => { + const { proxy } = setup() + const frozen = Object.freeze({ child: proxy.a }) + const result = untrackResult(frozen) + // Can't repair in place — proxies survive. Frozen raw state subtrees + // are skipped for the same check, which is the common (safe) case. + expect(isTrackingProxy(result.child)).toBe(true) + }) +}) + +describe('untrackResult with derived arrays', () => { + interface Row { + id: number + status: string + score: number + } + + function setupArrayState() { + const registry = createPathSignalRegistry(alienEngine) + const rows: Row[] = [] + for (let i = 0; i < 10; i++) { + rows.push({ id: i, status: i % 2 === 0 ? 'even' : 'odd', score: i * 10 }) + } + const state = { rows, extra: { x: 1 } } + for (const r of rows) Object.freeze(r) + Object.freeze(rows) + Object.freeze(state.extra) + Object.freeze(state) + const proxy = createTrackingProxy( + state, + '', + registry, + registry.proxyCache, + ) as typeof state + return { state, proxy } + } + + // Mimic one hook evaluation: run the selector, untrack the result. + function evaluate( + proxy: ReturnType['proxy'], + selector: (s: ReturnType['proxy']) => R, + ): R { + return untrackResult(selector(proxy)) + } + + it('untracks filter() results to raw elements', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => + s.rows.filter((r) => r.status === 'even'), + ) + expect(result).toHaveLength(5) + for (let i = 0; i < result.length; i++) { + expect(isTrackingProxy(result[i])).toBe(false) + } + expect(result[0]).toBe(state.rows[0]) + expect(result[4]).toBe(state.rows[8]) + }) + + it('handles filter().sort() — user mutation of a library-built array', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => + s.rows + .filter((r) => r.status === 'even') + .sort((a, b) => b.score - a.score), + ) + expect(result.map((r) => r.id)).toEqual([8, 6, 4, 2, 0]) + for (const r of result) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(result[0]).toBe(state.rows[8]) + }) + + it('handles an element replaced with a user-built container', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => { + const matches: unknown[] = s.rows.filter((r) => r.status === 'even') + matches[0] = { wrapped: s.extra } + return matches + }) + expect(isTrackingProxy((result[0] as { wrapped: object }).wrapped)).toBe( + false, + ) + expect((result[0] as { wrapped: object }).wrapped).toBe(state.extra) + expect(isTrackingProxy(result[1])).toBe(false) + }) + + it('untracks slice() results', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => s.rows.slice(2, 5)) + expect(result).toHaveLength(3) + for (const r of result) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(result[0]).toBe(state.rows[2]) + }) + + it('untracks library-built arrays embedded in user containers', () => { + const { state, proxy } = setupArrayState() + const result = evaluate(proxy, (s) => ({ + matches: s.rows.filter((r) => r.score > 50), + first: s.rows[0], + })) + for (const r of result.matches) { + expect(isTrackingProxy(r)).toBe(false) + } + expect(isTrackingProxy(result.first)).toBe(false) + expect(result.first).toBe(state.rows[0]) + }) +}) diff --git a/test/signals/useSignalSelector.spec.tsx b/test/signals/useSignalSelector.spec.tsx new file mode 100644 index 000000000..389ef3461 --- /dev/null +++ b/test/signals/useSignalSelector.spec.tsx @@ -0,0 +1,877 @@ +/* eslint-disable react/prop-types */ + +import * as rtl from '@testing-library/react' +import React, { useLayoutEffect } from 'react' +import { + configureStore, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit' +import { SignalProvider, useSignalSelector } from '../../src/signals' + +// ============================================================================ +// Test State Shape (RTK slices) +// ============================================================================ + +interface Todo { + id: number + text: string + completed: boolean +} + +interface Counter { + id: string + label: string + value: number +} + +const todosSlice = createSlice({ + name: 'todos', + initialState: [ + { id: 1, text: 'Learn Redux', completed: true }, + { id: 2, text: 'Learn Signals', completed: false }, + { id: 3, text: 'Build POC', completed: false }, + ] as Todo[], + reducers: { + toggle(state, action: PayloadAction) { + const todo = state.find((t) => t.id === action.payload) + if (todo) todo.completed = !todo.completed + }, + add(state, action: PayloadAction<{ id: number; text: string }>) { + state.push({ + id: action.payload.id, + text: action.payload.text, + completed: false, + }) + }, + remove(state, action: PayloadAction) { + return state.filter((t) => t.id !== action.payload) + }, + }, +}) + +const countersSlice = createSlice({ + name: 'counters', + initialState: { + counter1: { id: 'counter1', label: 'Clicks', value: 0 } as Counter, + counter2: { id: 'counter2', label: 'Views', value: 42 } as Counter, + }, + reducers: { + increment(state, action: PayloadAction<'counter1' | 'counter2'>) { + state[action.payload].value += 1 + }, + }, +}) + +const filterSlice = createSlice({ + name: 'filter', + initialState: 'all' as 'all' | 'active' | 'completed', + reducers: { + set(_state, action: PayloadAction<'all' | 'active' | 'completed'>) { + return action.payload + }, + }, +}) + +function createTestStore() { + return configureStore({ + reducer: { + todos: todosSlice.reducer, + counters: countersSlice.reducer, + filter: filterSlice.reducer, + }, + }) +} + +type TestStore = ReturnType +type TestState = ReturnType + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createTrackedSelector( + selector: (state: S) => T, +): { selector: (state: S) => T; getCalls: () => number; reset: () => void } { + let calls = 0 + const tracked = (state: S): T => { + calls++ + return selector(state) + } + return { + selector: tracked, + getCalls: () => calls, + reset: () => { + calls = 0 + }, + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('useSignalSelector', () => { + let store: TestStore + + beforeEach(() => { + store = createTestStore() + }) + + afterEach(() => { + rtl.cleanup() + }) + + // ========================================================================== + // Core Functionality (ported from prototype #1) + // ========================================================================== + + describe('core functionality', () => { + it('selects the state on initial render', () => { + let result: string | undefined + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + + useLayoutEffect(() => { + result = filter + }, [filter]) + + return
{filter}
+ } + + rtl.render( + + + , + ) + + expect(result).toBe('all') + }) + + it('selects the state and renders when store updates', () => { + const selector = vi.fn((state: TestState) => state.filter) + let result: string | undefined + + const Comp = () => { + const filter = useSignalSelector(selector) + + useLayoutEffect(() => { + result = filter + }, [filter]) + + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + expect(result).toBe('active') + }) + + it('handles nested object selection', () => { + let result: number | undefined + const Comp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + + useLayoutEffect(() => { + result = value + }, [value]) + + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result).toBe(0) + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('value').textContent).toBe('1') + expect(result).toBe(1) + }) + + it('handles array selection', () => { + let result: Todo | undefined + const Comp = () => { + const todo = useSignalSelector((state: TestState) => state.todos[0]) + + useLayoutEffect(() => { + result = todo + }, [todo]) + + return ( +
{todo.completed ? 'done' : 'pending'}
+ ) + } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(result?.completed).toBe(true) + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + expect(getByTestId('todo').textContent).toBe('pending') + expect(result?.completed).toBe(false) + }) + + it('unsubscribes when component unmounts', () => { + const selector = vi.fn((state: TestState) => state.filter) + + const Comp = () => { + const filter = useSignalSelector(selector) + return
{filter}
+ } + + const { unmount } = rtl.render( + + + , + ) + + const callsBeforeUnmount = selector.mock.calls.length + + unmount() + + // Dispatch after unmount + store.dispatch(filterSlice.actions.set('completed')) + + // Selector should not have been called again + expect(selector.mock.calls.length).toBe(callsBeforeUnmount) + }) + }) + + // ========================================================================== + // Selector Identity Handling (ported from prototype #1) + // ========================================================================== + + describe('selector identity handling', () => { + it('handles inline selectors', () => { + const renders: string[] = [] + const Comp = () => { + // Inline selector - new function every render + const filter = useSignalSelector((state: TestState) => state.filter) + renders.push(filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(renders[0]).toBe('all') + + rtl.act(() => { + store.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + expect(renders[renders.length - 1]).toBe('active') + }) + + it('re-tracks when selector closure changes via key remount', () => { + const CounterDisplay = ({ id }: { id: 'counter1' | 'counter2' }) => { + const counter = useSignalSelector( + (state: TestState) => state.counters[id], + ) + return ( +
+ {counter?.label}: {counter?.value} +
+ ) + } + + const Parent = () => { + const [selectedId, setSelectedId] = React.useState< + 'counter1' | 'counter2' + >('counter1') + return ( + + + + + ) + } + + const { getByTestId, getByText } = rtl.render() + + expect(getByTestId('display-counter1').textContent).toBe('Clicks: 0') + + // Switch to counter2 + rtl.act(() => { + getByText('Switch').click() + }) + + expect(getByTestId('display-counter2').textContent).toBe('Views: 42') + }) + }) + + // ========================================================================== + // Selective Execution — Key POC Validation (ported from prototype #1) + // ========================================================================== + + describe('selective execution (key POC validation)', () => { + it('todo selector does not run when counter changes', () => { + let todoSelectorCalls = 0 + let counterSelectorCalls = 0 + + const TodoComp = () => { + const todo = useSignalSelector((state: TestState) => { + todoSelectorCalls++ + return state.todos[0] + }) + return
{todo.text}
+ } + + const CounterComp = () => { + const counter = useSignalSelector((state: TestState) => { + counterSelectorCalls++ + return state.counters.counter1 + }) + return
{counter.value}
+ } + + rtl.render( + + + + , + ) + + // Mount: one probe run each, plus one extra from the default + // stabilityCheck: 'once' dev check + expect(todoSelectorCalls).toBe(2) + expect(counterSelectorCalls).toBe(2) + + const todoCallsBefore = todoSelectorCalls + const counterCallsBefore = counterSelectorCalls + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // Counter selector runs twice: once on promotion (old inline ref), + // once when the re-render swaps in the new inline function. + // The todo selector must NOT run at all. + expect(counterSelectorCalls - counterCallsBefore).toBe(2) + expect(todoSelectorCalls - todoCallsBefore).toBe(0) + }) + + it('counter selector does not run when todo changes', () => { + const todoSelector = createTrackedSelector( + (state: TestState) => state.todos[0], + ) + const counterSelector = createTrackedSelector( + (state: TestState) => state.counters.counter1, + ) + + const TodoComp = () => { + const todo = useSignalSelector(todoSelector.selector) + return ( +
{todo.completed ? 'done' : 'pending'}
+ ) + } + + const CounterComp = () => { + const counter = useSignalSelector(counterSelector.selector) + return
{counter.value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + todoSelector.reset() + counterSelector.reset() + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + expect(getByTestId('todo').textContent).toBe('pending') + // Stable selector ref: exactly one run on promotion, no swap re-run + expect(todoSelector.getCalls()).toBe(1) + expect(counterSelector.getCalls()).toBe(0) + }) + + it('filter selector does not run when todos change', () => { + const filterSelector = createTrackedSelector( + (state: TestState) => state.filter, + ) + const todoSelector = createTrackedSelector( + (state: TestState) => state.todos, + ) + + const FilterComp = () => { + const filter = useSignalSelector(filterSelector.selector) + return
{filter}
+ } + + const TodoListComp = () => { + const todos = useSignalSelector(todoSelector.selector) + return
{todos.length}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + filterSelector.reset() + todoSelector.reset() + + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New todo' })) + }) + + expect(getByTestId('todos').textContent).toBe('4') + // Stable selector ref: exactly one run on promotion, no swap re-run + expect(todoSelector.getCalls()).toBe(1) + expect(filterSelector.getCalls()).toBe(0) + }) + + it('counter1 selector does not run when counter2 changes', () => { + // Note: Both selectors read through `state.counters.counterX`, so they + // share the intermediate `"counters"` version-counter signal. When either + // counter changes, both selectors RE-RUN (signal dep on parent object). + // However, the correct component still renders the correct value — + // selectivity at the object level is coarser than leaf level. + // Leaf-returning selectors (e.g., `state.counters.counter1.value`) get + // full propagation cutoff via Object.is on the computed result. + const counter1Selector = createTrackedSelector( + (state: TestState) => state.counters.counter1.value, + ) + const counter2Selector = createTrackedSelector( + (state: TestState) => state.counters.counter2.value, + ) + + let counter1Renders = 0 + let counter2Renders = 0 + + const Counter1Comp = () => { + const value = useSignalSelector(counter1Selector.selector) + counter1Renders++ + return
{value}
+ } + + const Counter2Comp = () => { + const value = useSignalSelector(counter2Selector.selector) + counter2Renders++ + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + , + ) + + const renders1After = counter1Renders + const renders2After = counter2Renders + + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + // counter1 value changed → exactly one re-render + expect(getByTestId('counter1').textContent).toBe('1') + expect(counter1Renders).toBe(renders1After + 1) + + // counter2 value did NOT change → should NOT re-render + // (Object.is cutoff on the primitive computed result) + expect(getByTestId('counter2').textContent).toBe('42') + expect(counter2Renders).toBe(renders2After) + }) + }) + + // ========================================================================== + // Equality Functions (ported from prototype #1) + // ========================================================================== + + describe('equality functions', () => { + it('allows custom equality functions', () => { + let renderCount = 0 + + const Comp = () => { + const todosLength = useSignalSelector( + (state: TestState) => ({ length: state.todos.length }), + (a, b) => a.length === b.length, + ) + renderCount++ + return
{todosLength.length}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('length').textContent).toBe('3') + const rendersAfterMount = renderCount + + // Toggle a todo — array changes but length stays same + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + // Should NOT have re-rendered because length is the same + expect(renderCount).toBe(rendersAfterMount) + + // Add a todo — length changes + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New todo' })) + }) + + expect(getByTestId('length').textContent).toBe('4') + expect(renderCount).toBe(rendersAfterMount + 1) + }) + }) + + // ========================================================================== + // Frozen State Handling (new — specific to prototype #2) + // ========================================================================== + + describe('frozen state handling', () => { + it('works with configureStore (Immer freezes state)', () => { + // configureStore uses Immer which freezes state — this is the real + // production scenario that killed prototype #1 + const frozenStore = createTestStore() + + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + + // Verify slice state is frozen by Immer (individual slices, not root) + expect(Object.isFrozen(frozenStore.getState().todos)).toBe(true) + expect(Object.isFrozen(frozenStore.getState().counters)).toBe(true) + + rtl.act(() => { + frozenStore.dispatch(filterSlice.actions.set('active')) + }) + + expect(getByTestId('filter').textContent).toBe('active') + }) + + it('works with deeply frozen nested objects', () => { + const frozenStore = createTestStore() + + const Comp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + // Verify nested state is frozen + expect(Object.isFrozen(frozenStore.getState().counters)).toBe(true) + expect(Object.isFrozen(frozenStore.getState().counters.counter1)).toBe( + true, + ) + + expect(getByTestId('value').textContent).toBe('0') + + rtl.act(() => { + frozenStore.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('value').textContent).toBe('1') + }) + }) + + // ========================================================================== + // Dynamic Dependencies (new — specific to prototype #2) + // ========================================================================== + + describe('dynamic dependencies', () => { + it('re-tracks when selector accesses different paths based on state', () => { + const selectedIdSlice = createSlice({ + name: 'selectedId', + initialState: 1, + reducers: { + set(_state, action: PayloadAction) { + return action.payload + }, + }, + }) + + const itemsSlice = createSlice({ + name: 'items', + initialState: { 1: 'A', 2: 'B', 3: 'C' } as Record, + reducers: { + update(state, action: PayloadAction<{ id: number; value: string }>) { + state[action.payload.id] = action.payload.value + }, + }, + }) + + const dynStore = configureStore({ + reducer: { + selectedId: selectedIdSlice.reducer, + items: itemsSlice.reducer, + }, + }) + type DynState = ReturnType + + let selectorCalls = 0 + + const Comp = () => { + const item = useSignalSelector((state: DynState) => { + selectorCalls++ + return state.items[state.selectedId] + }) + return
{item}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('item').textContent).toBe('A') + + // Change selectedId to 2 → now tracks items.2 + rtl.act(() => { + dynStore.dispatch(selectedIdSlice.actions.set(2)) + }) + + expect(getByTestId('item').textContent).toBe('B') + + // Change items.1 → selector may re-run because of shared `"items"` + // parent version-counter signal, but the computed returns the same + // value ("B") so React does NOT re-render. + rtl.act(() => { + dynStore.dispatch(itemsSlice.actions.update({ id: 1, value: 'A2' })) + }) + + expect(getByTestId('item').textContent).toBe('B') + }) + }) + + // ========================================================================== + // Array Mutations (new — specific to prototype #2) + // ========================================================================== + + describe('array mutations', () => { + it('handles array item addition', () => { + const Comp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('count').textContent).toBe('3') + + rtl.act(() => { + store.dispatch(todosSlice.actions.add({ id: 4, text: 'New' })) + }) + + expect(getByTestId('count').textContent).toBe('4') + }) + + it('handles array item removal', () => { + const Comp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('count').textContent).toBe('3') + + rtl.act(() => { + store.dispatch(todosSlice.actions.remove(1)) + }) + + expect(getByTestId('count').textContent).toBe('2') + }) + + it('handles array item property change', () => { + const Comp = () => { + const text = useSignalSelector( + (state: TestState) => state.todos[0].text, + ) + return
{text}
+ } + + const { getByTestId } = rtl.render( + + + , + ) + + expect(getByTestId('text').textContent).toBe('Learn Redux') + + rtl.act(() => { + store.dispatch(todosSlice.actions.toggle(1)) + }) + + // text didn't change, only completed did + expect(getByTestId('text').textContent).toBe('Learn Redux') + }) + }) + + // ========================================================================== + // NOOP Dispatches + // ========================================================================== + + describe('noop dispatches', () => { + it('does not re-render on NOOP dispatch', () => { + let renderCount = 0 + + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + renderCount++ + return
{filter}
+ } + + rtl.render( + + + , + ) + + const rendersAfterMount = renderCount + + rtl.act(() => { + store.dispatch({ type: 'NOOP' } as any) + }) + + expect(renderCount).toBe(rendersAfterMount) + }) + }) + + // ========================================================================== + // Error Handling + // ========================================================================== + + describe('error handling', () => { + it('throws when used outside SignalProvider', () => { + const Comp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + expect(() => { + rtl.render() + }).toThrow('useSignalSelector must be used within a ') + }) + }) + + // ========================================================================== + // Multiple Components + // ========================================================================== + + describe('multiple components', () => { + it('multiple components with different selectors all render correctly', () => { + const FilterComp = () => { + const filter = useSignalSelector((state: TestState) => state.filter) + return
{filter}
+ } + + const TodoComp = () => { + const count = useSignalSelector( + (state: TestState) => state.todos.length, + ) + return
{count}
+ } + + const CounterComp = () => { + const value = useSignalSelector( + (state: TestState) => state.counters.counter1.value, + ) + return
{value}
+ } + + const { getByTestId } = rtl.render( + + + + + , + ) + + expect(getByTestId('filter').textContent).toBe('all') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('0') + + // Update counter — only counter component should update + rtl.act(() => { + store.dispatch(countersSlice.actions.increment('counter1')) + }) + + expect(getByTestId('filter').textContent).toBe('all') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('1') + + // Update filter — only filter component should update + rtl.act(() => { + store.dispatch(filterSlice.actions.set('completed')) + }) + + expect(getByTestId('filter').textContent).toBe('completed') + expect(getByTestId('todos').textContent).toBe('3') + expect(getByTestId('counter').textContent).toBe('1') + }) + }) +}) diff --git a/test/signals/zombieChildren.spec.tsx b/test/signals/zombieChildren.spec.tsx new file mode 100644 index 000000000..8c632a576 --- /dev/null +++ b/test/signals/zombieChildren.spec.tsx @@ -0,0 +1,246 @@ +/** + * Zombie children and selector error semantics. + * + * React mounts bottom-up, so children subscribe before parents. When a + * dispatch removes an entity that a still-mounted child selects, the + * child's selector re-runs against the new state and may throw (the + * classic "zombie child" problem — same tradeoff stock useSelector + * accepted when it gave up connect's top-down subscription cascade). + * + * Required semantics (matching stock useSelector under React's + * useSyncExternalStore): + * - A throwing selector must NOT crash `store.dispatch`. + * - Other components' subscriptions must still be notified. + * - If a parent re-render unmounts the throwing component, the error + * dissolves — the UI just moves on. + * - If the component stays mounted, the error surfaces during render, + * where an error boundary owns it. + * + * Also covers the prune-signal bug this scenario exposed: prune() + * previously wrote a signal's own value back to it, which never fires + * for non-numeric primitives under alien-signals' `!==` equality — + * leaving selectors that only read string/boolean leaves of a removed + * entity permanently stale. + */ +import { configureStore, createSlice } from '@reduxjs/toolkit' +import * as rtl from '@testing-library/react' +import * as React from 'react' +import { alienEngine } from '../../src/signals/engine' +import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry' +import { SignalProvider } from '../../src/signals/SignalProvider' +import { useSignalSelector } from '../../src/signals/useSignalSelector' + +interface Item { + id: number + name: string + starred: boolean +} + +const slice = createSlice({ + name: 'items', + initialState: { + list: [ + { id: 1, name: 'one', starred: false }, + { id: 2, name: 'two', starred: true }, + ] as Item[], + }, + reducers: { + removeSecond(state) { + state.list = state.list.filter((i) => i.id !== 2) + }, + restoreSecond(state) { + state.list.push({ id: 2, name: 'two', starred: true }) + }, + }, +}) + +const makeStore = () => configureStore({ reducer: { items: slice.reducer } }) +type RootState = ReturnType['getState']> + +class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null } + static getDerivedStateFromError(error: Error) { + return { error } + } + render() { + if (this.state.error) { + return
{this.state.error.message}
+ } + return this.props.children + } +} + +describe('prune fires non-numeric primitive leaf signals', () => { + it.each([ + ['string', 'two'], + ['boolean', true], + ['null', null], + ])('a computed depending on a pruned %s leaf re-runs', (_kind, value) => { + const registry = createPathSignalRegistry(alienEngine) + registry.getOrCreate('items.child', value) + + let runs = 0 + const c = alienEngine.computed(() => { + runs++ + return registry.has('items.child') + ? registry.getOrCreate('items.child', value).get() + : 'pruned' + }) + expect(c.get()).toBe(value) + expect(runs).toBe(1) + + registry.prune('items') + + expect(c.get()).toBe('pruned') + expect(runs).toBe(2) + }) + + it('a selector reading only a string leaf of a removed entity re-renders', () => { + const store = makeStore() + + function Watcher() { + // Reads ONLY items.list.{id:2}.name — a string leaf signal. + // Before the prune fix this stayed 'two' forever after removal. + const name = useSignalSelector( + (s: RootState) => s.items.list[1]?.name ?? 'gone', + ) + return
{name}
+ } + + rtl.render( + + + , + ) + expect(rtl.screen.getByTestId('watcher').textContent).toBe('two') + + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + expect(rtl.screen.getByTestId('watcher').textContent).toBe('gone') + }) +}) + +describe('zombie children', () => { + it('dispatch survives and the zombie child unmounts cleanly', () => { + const store = makeStore() + + function Child({ id }: { id: number }) { + // Classic zombie pattern: assumes the item exists. + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === id)!.name, + ) + return
{name}
+ } + + function Parent() { + const ids = useSignalSelector((s: RootState) => + s.items.list.map((i) => i.id), + ) + return ( + <> + {ids.map((id) => ( + + ))} + + ) + } + + rtl.render( + + + + + , + ) + expect(rtl.screen.getByTestId('child-2').textContent).toBe('two') + + // Must not throw out of dispatch. + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + // Parent re-rendered and unmounted the zombie; the error dissolved + // without reaching the boundary. + expect(rtl.screen.queryByTestId('child-2')).toBeNull() + expect(rtl.screen.getByTestId('child-1').textContent).toBe('one') + expect(rtl.screen.queryByTestId('boundary')).toBeNull() + }) + + it('a throwing selector on a component that stays mounted reaches an error boundary', () => { + const store = makeStore() + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + function StubbornWatcher() { + // Not conditionally rendered by any parent — stays mounted after + // the entity is removed, so the selector throw must surface. + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === 2)!.name, + ) + return
{name}
+ } + + rtl.render( + + + + + , + ) + expect(rtl.screen.getByTestId('stubborn').textContent).toBe('two') + + // Dispatch itself must survive; the error lands in the boundary. + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + expect(rtl.screen.queryByTestId('stubborn')).toBeNull() + expect(rtl.screen.getByTestId('boundary').textContent).toMatch( + /Cannot read properties of undefined/, + ) + consoleSpy.mockRestore() + }) + + it('other components are still notified when one selector throws', () => { + const store = makeStore() + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + function StubbornWatcher() { + const name = useSignalSelector( + (s: RootState) => s.items.list.find((i) => i.id === 2)!.name, + ) + return
{name}
+ } + + function CountWatcher() { + const count = useSignalSelector((s: RootState) => s.items.list.length) + return
{count}
+ } + + // StubbornWatcher mounts FIRST so its effect flushes before + // CountWatcher's during reconcile — the throw must not stop the + // rest of the flush. + rtl.render( + + + + + + , + ) + expect(rtl.screen.getByTestId('count').textContent).toBe('2') + + rtl.act(() => { + store.dispatch(slice.actions.removeSecond()) + }) + + expect(rtl.screen.getByTestId('count').textContent).toBe('1') + expect(rtl.screen.getByTestId('boundary').textContent).toMatch( + /Cannot read properties of undefined/, + ) + consoleSpy.mockRestore() + }) +}) diff --git a/test/typetests/rtk-query-hooks-module.test-d.tsx b/test/typetests/rtk-query-hooks-module.test-d.tsx new file mode 100644 index 000000000..b2972c9e2 --- /dev/null +++ b/test/typetests/rtk-query-hooks-module.test-d.tsx @@ -0,0 +1,41 @@ +import { + buildCreateApi, + coreModule, + reactHooksModule, +} from '@reduxjs/toolkit/query/react' +import { useDispatch, useSelector, useStore } from '../../src/index' +import type { UseSelector } from '../../src/index' +import { createSignalSelectorHook, useSignalSelector } from '../../src/signals' +import type { UseSignalSelector } from '../../src/signals' + +type ModuleHooks = NonNullable< + NonNullable[0]>['hooks'] +> + +describe('type tests', () => { + test('useSignalSelector satisfies reactHooksModule hooks.useSelector', () => { + expectTypeOf(useSignalSelector).toMatchTypeOf() + expectTypeOf(useSignalSelector).toMatchTypeOf() + expectTypeOf>().toMatchTypeOf< + UseSelector<{ a: number }> + >() + expectTypeOf(createSignalSelectorHook()).toMatchTypeOf< + ModuleHooks['useSelector'] + >() + }) + + test('reactHooksModule accepts useSignalSelector alongside stock hooks', () => { + const createApi = buildCreateApi( + coreModule(), + reactHooksModule({ + hooks: { useDispatch, useSelector: useSignalSelector, useStore }, + }), + ) + expectTypeOf(createApi).toEqualTypeOf( + buildCreateApi( + coreModule(), + reactHooksModule({ hooks: { useDispatch, useSelector, useStore } }), + ), + ) + }) +}) diff --git a/test/typetests/signal-provider.test-d.tsx b/test/typetests/signal-provider.test-d.tsx new file mode 100644 index 000000000..56d36ecfe --- /dev/null +++ b/test/typetests/signal-provider.test-d.tsx @@ -0,0 +1,46 @@ +import type { Action, Store, UnknownAction } from 'redux' +import { SignalProvider } from '../../src/signals' + +type FooAction = Action<'foo'> + +declare const store: Store<{ foo: string }> +declare const fooStore: Store<{ foo: string }, FooAction> + +describe('type tests', () => { + test('SignalProvider rejects the same bad props as stock Provider', () => { + expectTypeOf(SignalProvider) + .parameter(0) + .not.toMatchTypeOf({ store, serverState: 'oops', children: 'foo' }) + + const App = () => { + return ( + + foo + + ) + } + }) + + test('SignalProvider generic order matches stock Provider ()', () => { + const Explicit = () => { + return ( + store={fooStore}> + foo +
+ ) + } + + const WrongOrder = () => { + return ( + // @ts-expect-error state type is the second parameter, not the first + store={fooStore}> + foo + + ) + } + }) +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index 752ad2b2f..64038b0f2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,7 +16,12 @@ "noUnusedParameters": false, "paths": { "@internal/*": ["./src/*"], - "react-redux": ["./src/index.ts"] // @remap-prod-remove-line + // Type-level counterpart to the `react-redux` alias in + // `vitest.config.mts`. The type tests resolve through tsconfig + // paths rather than the Vite alias, so they must point at the same + // stock entry — otherwise a benchmark override in `src/exports.ts` + // silently changes what the type tests are checking. + "react-redux": ["./test/entries/stock.ts"] // @remap-prod-remove-line }, "resolveJsonModule": true, "skipLibCheck": true, diff --git a/tsdown.config.mts b/tsdown.config.mts index 78455f6ac..756840159 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -11,67 +11,67 @@ if (process.env.NODE_ENV === 'production') { module.exports = require('./react-redux.production.min.cjs') } else { module.exports = require('./react-redux.development.cjs') +}`, + ) + await fs.writeFile( + path.join('dist/cjs/', 'signals.js'), + `'use strict' +if (process.env.NODE_ENV === 'production') { + module.exports = require('./react-redux-signals.production.min.cjs') +} else { + module.exports = require('./react-redux-signals.development.cjs') }`, ) } const tsconfig = 'tsconfig.build.json' -export default defineConfig((options): UserConfig[] => { - const commonOptions = { - entry: { - 'react-redux': 'src/index.ts', - }, - sourcemap: true, - // `pnpm clean` already removes `dist/`; letting each of the seven builds - // clean would race them against each other. - clean: false, - hash: false, - target: ['esnext'], - tsconfig, - dts: false, - report: false, - ...options, - } satisfies UserConfig +// The main entry and the `react-redux/signals` entry share almost all of their +// code. Building them in the same config makes Rolldown hoist that code into a +// shared `src.*` chunk and leaves `react-redux.mjs` as a re-export shim, which +// changes the published main artifact and breaks the publish-ci snapshot of +// which file each bundler resolves. Each entry therefore gets its own build of +// every variant, at the cost of duplicating the shared code into the signals +// bundles. +const entries = { + 'react-redux': 'src/index.ts', + 'react-redux-signals': 'src/index-signals.ts', +} - return [ - // Standard ESM, embedded `process.env.NODE_ENV` checks - { - ...commonOptions, - name: 'Modern ESM', - format: ['esm'], - outExtensions: () => ({ js: '.mjs' }), - }, - { - ...commonOptions, - name: 'ESM for RSC', - entry: { - rsc: 'src/index-rsc.ts', - }, +type Variant = { + name: string + suffix: string + options: Partial +} + +const variants: Variant[] = [ + // Standard ESM, embedded `process.env.NODE_ENV` checks + { + name: 'Modern ESM', + suffix: '', + options: { format: ['esm'], outExtensions: () => ({ js: '.mjs' }), }, + }, - // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension - // and optional chaining compiled away - { - ...commonOptions, - name: 'Legacy ESM, Webpack 4', - entry: { - 'react-redux.legacy-esm': 'src/index.ts', - }, + // Support Webpack 4 by pointing `"module"` to a file with a `.js` extension + // and optional chaining compiled away + { + name: 'Legacy ESM, Webpack 4', + suffix: '.legacy-esm', + options: { target: ['es2017'], format: ['esm'], outExtensions: () => ({ js: '.js' }), }, + }, - // Meant to be served up via CDNs like `unpkg`. - { - ...commonOptions, - name: 'Browser-ready ESM', - entry: { - 'react-redux.browser': 'src/index.ts', - }, + // Meant to be served up via CDNs like `unpkg`. + { + name: 'Browser-ready ESM', + suffix: '.browser', + options: { platform: 'browser', env: { NODE_ENV: 'production', @@ -80,12 +80,11 @@ export default defineConfig((options): UserConfig[] => { outExtensions: () => ({ js: '.mjs' }), minify: true, }, - { - ...commonOptions, - name: 'CJS Development', - entry: { - 'react-redux.development': 'src/index.ts', - }, + }, + { + name: 'CJS Development', + suffix: '.development', + options: { env: { NODE_ENV: 'development', }, @@ -93,12 +92,11 @@ export default defineConfig((options): UserConfig[] => { outDir: './dist/cjs/', outExtensions: () => ({ js: '.cjs' }), }, - { - ...commonOptions, - name: 'CJS production', - entry: { - 'react-redux.production.min': 'src/index.ts', - }, + }, + { + name: 'CJS production', + suffix: '.production.min', + options: { env: { NODE_ENV: 'production', }, @@ -110,12 +108,53 @@ export default defineConfig((options): UserConfig[] => { await writeCommonJSEntry() }, }, - { - ...commonOptions, - name: 'Type definitions', + }, + { + name: 'Type definitions', + suffix: '', + options: { format: ['esm'], dts: { emitDtsOnly: true }, outExtensions: () => ({ dts: '.d.ts' }), }, + }, +] + +export default defineConfig((options): UserConfig[] => { + const commonOptions = { + sourcemap: true, + // `pnpm clean` already removes `dist/`; letting each of the builds + // clean would race them against each other. + clean: false, + hash: false, + target: ['esnext'], + tsconfig, + dts: false, + report: false, + ...options, + } satisfies UserConfig + + const entryBuilds = Object.entries(entries).flatMap(([outName, source]) => + variants.map( + ({ name, suffix, options: variantOptions }): UserConfig => ({ + ...commonOptions, + name: `${name} (${outName})`, + entry: { [outName + suffix]: source }, + ...variantOptions, + }), + ), + ) + + return [ + ...entryBuilds, + { + ...commonOptions, + name: 'ESM for RSC', + entry: { + rsc: 'src/index-rsc.ts', + }, + format: ['esm'], + outExtensions: () => ({ js: '.mjs' }), + }, ] }) diff --git a/vitest.config.mts b/vitest.config.mts index 4487cfe62..93d3dfd5e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,9 +5,32 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['test/setup.ts'], globals: true, + server: { + deps: { + // RTK's `query/react` entry imports `react-redux` at runtime. + // Externalized deps are loaded by Node, which never applies the + // alias below: the import fails (this repo IS react-redux, so it + // isn't installed), and any copy Node did find would be a second + // module instance with its own context, ignoring TEST_IMPL. + // Inlining runs RTK through Vite so it shares the aliased instance + // the tests use. + inline: [/@reduxjs\/toolkit/], + }, + }, alias: { + // Which implementation the shared spec files run against. + // TEST_DIST=1 -> the built package + // TEST_IMPL=signals -> useSignalSelector / SignalProvider + // default -> stock hooks + // The signal and stock entries live in test/entries and present + // their implementation under the stock public names, so one spec + // file validates both without being copied. 'react-redux': new URL( - process.env.TEST_DIST ? 'node_modules/react-redux' : 'src/index.ts', + process.env.TEST_DIST + ? 'node_modules/react-redux' + : process.env.TEST_IMPL === 'signals' + ? 'test/entries/signals.ts' + : 'test/entries/stock.ts', import.meta.url, ).pathname, // this mapping is disabled as we want `dist` imports in the tests only to be used for "type-only" imports which don't play a role for jest diff --git a/website/_redirects b/website/_redirects index 5de03acd3..562ca59f4 100644 --- a/website/_redirects +++ b/website/_redirects @@ -5,6 +5,13 @@ /docs/* /:splat +# Lowercase aliases for camelCase API page URLs +/api/useselector /api/useSelector +/api/usedispatch /api/useDispatch +/api/usestore /api/useStore +/api/usesignalselector /api/useSignalSelector +/api/signalprovider /api/SignalProvider + # Renamed pages /introduction/quick-start /introduction/getting-started /next/introduction/quick-started /introduction/getting-started diff --git a/website/sidebars.ts b/website/sidebars.ts index 0c0683a81..6b2ca5c6d 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -35,7 +35,32 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'API Reference', - items: ['api/provider', 'api/hooks', 'api/connect', 'api/batch'], + items: [ + { + type: 'category', + label: 'Components', + collapsed: false, + items: ['api/provider', 'api/SignalProvider', 'api/connect'], + }, + { + type: 'category', + label: 'Hooks', + collapsed: false, + link: { type: 'doc', id: 'api/hooks' }, + items: [ + 'api/useSelector', + 'api/useDispatch', + 'api/useStore', + 'api/useSignalSelector', + ], + }, + { + type: 'category', + label: 'Other', + collapsed: false, + items: ['api/unwrap', 'api/batch'], + }, + ], }, { type: 'category',