react-fresh-key 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ // react-fresh-key v0.1.0 — vendored copy (TypeScript).
2
+ // This file is yours now: edit it freely. Docs: https://www.npmjs.com/package/react-fresh-key
3
+ /*!
4
+ MIT License
5
+
6
+ Copyright (c) 2026 Rafael Buzatto de Campos
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+ */
26
+ import { useState } from 'react'
27
+ import { areDepsEqual, isEqualDep, type DepsList } from './utils'
28
+
29
+ /**
30
+ * Returns a numeric key that changes whenever any value in `deps` changes.
31
+ * Values are compared with `Object.is` — like React's own dependency
32
+ * comparison — except that plain objects and ordinary arrays are compared
33
+ * one level deep. Distinct objects with equal content preserve the key;
34
+ * `[{ id }]` written inline therefore converges instead of looping.
35
+ *
36
+ * A hook cannot remount the component it runs in, so apply the key to an
37
+ * inner subtree:
38
+ *
39
+ * ```tsx
40
+ * function Profile({ userId }: { userId: string }) {
41
+ * const key = useRemountKey([userId])
42
+ * return <ProfileForm key={key} userId={userId} />
43
+ * }
44
+ * ```
45
+ *
46
+ * The key is derived purely during render (the setState-in-render
47
+ * "derived state" pattern), so it is SSR-deterministic and StrictMode-safe.
48
+ * In the render where deps change, the *new* key is returned immediately.
49
+ *
50
+ * **Stability contract** — put primitives, references you already hold
51
+ * (props, state, memoized values), or flat plain objects/arrays in `deps`.
52
+ * A value that is a *new, non-equal* reference on every render — a nested
53
+ * object literal, `new Map()`, `new Date()`, a class instance built inline —
54
+ * can never compare equal and will trip React's re-render limit. Hold such
55
+ * values in state or `useMemo` first.
56
+ *
57
+ * @param deps Watched values, compared using {@link DepsList} rules. Treat the
58
+ * list and its values as immutable; changing their contents in place is not
59
+ * detected reliably. An empty list keeps the initial key.
60
+ * @returns A key local to this hook instance, starting at `0` and increasing
61
+ * when dependencies change. Apply it to a descendant element's `key` prop.
62
+ */
63
+ export function useRemountKey(deps: DepsList): number {
64
+ const [state, setState] = useState<{ deps: DepsList; key: number }>({
65
+ deps,
66
+ key: 0,
67
+ })
68
+ if (!areDepsEqual(state.deps, deps)) {
69
+ // Documented React pattern: setting state during render restarts the
70
+ // render immediately with the new state, before touching the DOM.
71
+ const key = state.key + 1
72
+ setState({ deps, key })
73
+ return key
74
+ }
75
+ return state.key
76
+ }
77
+
78
+ /**
79
+ * Predicate flavor of {@link useRemountKey}: tracks an arbitrary `value` and
80
+ * bumps the key when `when(prev, next)` returns `true` for a changed value.
81
+ *
82
+ * `prev` is the last recorded value. Whenever a changed value is observed,
83
+ * the snapshot advances even if the predicate returns `false`. Equal values
84
+ * retain the existing snapshot and skip the predicate. Equality uses
85
+ * `Object.is`, or a one-level shallow comparison for plain objects and ordinary
86
+ * arrays, so a flat inline object (`useRemountKeyWhen({ id, tab }, …)`) converges.
87
+ *
88
+ * ```tsx
89
+ * const key = useRemountKeyWhen(user, (prev, next) => prev.id !== next.id)
90
+ * ```
91
+ *
92
+ * **Stability contract**: the same as {@link useRemountKey} — a value that is
93
+ * a new, non-equal reference on every render (nested literals, Map, Date,
94
+ * class instances built inline) cannot converge; hold it in state or `useMemo`.
95
+ *
96
+ * @param value The immutable value to track. Replace changed objects instead
97
+ * of mutating them in place.
98
+ * @param when A pure render-time predicate comparing the last recorded value
99
+ * with the new value. It is skipped on initial render and for equal values;
100
+ * changing only the predicate does not trigger a comparison.
101
+ * @returns A key starting at `0`, increased when a changed value satisfies
102
+ * `when`. Apply it to a descendant element; it cannot remount this hook's owner.
103
+ */
104
+ export function useRemountKeyWhen<T>(value: T, when: (prev: T, next: T) => boolean): number {
105
+ const [state, setState] = useState<{ value: T; key: number }>({
106
+ value,
107
+ key: 0,
108
+ })
109
+ if (!isEqualDep(state.value, value)) {
110
+ const key = when(state.value, value) ? state.key + 1 : state.key
111
+ setState({ value, key })
112
+ return key
113
+ }
114
+ return state.key
115
+ }
@@ -0,0 +1,93 @@
1
+ // react-fresh-key v0.1.0 — vendored copy (TypeScript).
2
+ // This file is yours now: edit it freely. Docs: https://www.npmjs.com/package/react-fresh-key
3
+ /*!
4
+ MIT License
5
+
6
+ Copyright (c) 2026 Rafael Buzatto de Campos
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+ */
26
+ import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'
27
+ import { areDepsEqual, type DepsList } from './utils'
28
+
29
+ function resolveInitial<T>(initial: T | (() => T)): T {
30
+ return typeof initial === 'function' ? (initial as () => T)() : initial
31
+ }
32
+
33
+ /**
34
+ * Like `useState`, but the state snaps back to `initial` whenever any value
35
+ * in `deps` changes.
36
+ *
37
+ * This is the cheap alternative to a full remount: only this piece of state
38
+ * resets — DOM state (focus, scroll, uncontrolled inputs), refs, and effects
39
+ * are untouched. Reach for this first; remount only when you need everything
40
+ * gone.
41
+ *
42
+ * ```tsx
43
+ * function Comment({ postId }: { postId: string }) {
44
+ * const [draft, setDraft] = useResettableState('', [postId])
45
+ * // draft clears when the user navigates to another post
46
+ * }
47
+ * ```
48
+ *
49
+ * Reset happens purely during render (no effect, no flash of stale state),
50
+ * and the render in which deps change already sees the fresh value — so
51
+ * code after the hook never observes state that belongs to the old deps.
52
+ *
53
+ * `deps` follow {@link DepsList} comparison rules: `Object.is`, with plain
54
+ * objects and ordinary arrays compared one level deep. Dependencies must be
55
+ * able to compare equal across rerenders: nested literals or new class
56
+ * instances created on every render can cause a re-render loop. Keep those
57
+ * values in state or memoize them, and replace changed values rather than
58
+ * mutating them in place.
59
+ *
60
+ * @param initial The value, or a pure lazy initializer, used on mount and on
61
+ * each dependency change. Resets use the current render's `initial`; changing
62
+ * only this argument does not reset existing state. Wrap a function-valued
63
+ * initial state in an initializer, as with React's `useState`.
64
+ * @param deps Immutable watched values that determine when to reset. An empty
65
+ * list disables dependency-driven resets.
66
+ * @returns The current state and a stable setter accepting either the next
67
+ * value or a pure updater function. A dependency change returns fresh state
68
+ * in the same render, without remounting the component.
69
+ */
70
+ export function useResettableState<T>(
71
+ initial: T | (() => T),
72
+ deps: DepsList
73
+ ): [T, Dispatch<SetStateAction<T>>] {
74
+ const [state, setState] = useState<{ deps: DepsList; value: T }>(() => ({
75
+ deps,
76
+ value: resolveInitial(initial),
77
+ }))
78
+
79
+ const setValue = useCallback<Dispatch<SetStateAction<T>>>((action) => {
80
+ setState((s) => ({
81
+ deps: s.deps,
82
+ value: typeof action === 'function' ? (action as (prev: T) => T)(s.value) : action,
83
+ }))
84
+ }, [])
85
+
86
+ if (!areDepsEqual(state.deps, deps)) {
87
+ const value = resolveInitial(initial)
88
+ setState({ deps, value })
89
+ return [value, setValue]
90
+ }
91
+
92
+ return [state.value, setValue]
93
+ }
@@ -0,0 +1,109 @@
1
+ // react-fresh-key v0.1.0 — vendored copy (TypeScript).
2
+ // This file is yours now: edit it freely. Docs: https://www.npmjs.com/package/react-fresh-key
3
+ /*!
4
+ MIT License
5
+
6
+ Copyright (c) 2026 Rafael Buzatto de Campos
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+ */
26
+ /**
27
+ * Watched values used by remount and state-reset APIs.
28
+ *
29
+ * List length and corresponding entries are compared. Entries use `Object.is`,
30
+ * except that plain objects and ordinary arrays compare one level deep using
31
+ * their own enumerable string and symbol properties; arrays also compare
32
+ * length. Equal content preserves state even when object identities differ.
33
+ * Nested objects, functions, Maps, Sets, Dates, and class instances (including
34
+ * array subclasses) compare by identity.
35
+ *
36
+ * Treat the list and its values as immutable. A new list is fine when its
37
+ * entries compare equal; values created anew on every render that cannot
38
+ * compare equal can cause hooks to hit React's re-render limit.
39
+ */
40
+ export type DepsList = readonly unknown[]
41
+
42
+ function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
43
+ if (value === null || typeof value !== 'object') return false
44
+ const proto = Object.getPrototypeOf(value)
45
+ return proto === Object.prototype || proto === null
46
+ }
47
+
48
+ /** An `Array` proper — not a subclass, which is treated as a class instance. */
49
+ function isOrdinaryArray(value: unknown): value is unknown[] {
50
+ return Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype
51
+ }
52
+
53
+ /** Own enumerable keys, including symbol keys (which `Object.keys` omits). */
54
+ function ownEnumerableKeys(value: object): PropertyKey[] {
55
+ const keys: PropertyKey[] = Object.keys(value)
56
+ for (const sym of Object.getOwnPropertySymbols(value)) {
57
+ if (Object.prototype.propertyIsEnumerable.call(value, sym)) keys.push(sym)
58
+ }
59
+ return keys
60
+ }
61
+
62
+ /** Shallow-compare two objects: same own enumerable keys, `Object.is` per value. */
63
+ export function shallowEqual(a: object, b: object): boolean {
64
+ if (Object.is(a, b)) return true
65
+ const keysA = ownEnumerableKeys(a)
66
+ const keysB = ownEnumerableKeys(b)
67
+ if (keysA.length !== keysB.length) return false
68
+ for (const key of keysA) {
69
+ if (
70
+ !Object.prototype.propertyIsEnumerable.call(b, key) ||
71
+ !Object.is((a as Record<PropertyKey, unknown>)[key], (b as Record<PropertyKey, unknown>)[key])
72
+ ) {
73
+ return false
74
+ }
75
+ }
76
+ return true
77
+ }
78
+
79
+ /**
80
+ * Equality for a single watched value: `Object.is`, except that two *plain*
81
+ * objects (`{}` / `Object.create(null)`) or two ordinary arrays are compared
82
+ * one level deep — every own enumerable property, symbol keys included, plus
83
+ * array length. This lets the common inline-literal shapes converge —
84
+ * `useRemountKey([{ id }])`, `useRemountKeyWhen({ id, tab }, …)` — without
85
+ * hiding real changes. Anything else (class instances, including `Array`
86
+ * subclasses; Map/Set; Date; functions; nested objects' inner values) is
87
+ * compared by identity.
88
+ */
89
+ export function isEqualDep(a: unknown, b: unknown): boolean {
90
+ if (Object.is(a, b)) return true
91
+ if (isOrdinaryArray(a) && isOrdinaryArray(b)) return a.length === b.length && shallowEqual(a, b)
92
+ if (isPlainObject(a) && isPlainObject(b)) return shallowEqual(a, b)
93
+ return false
94
+ }
95
+
96
+ /** Compare two dependency lists element-wise with {@link isEqualDep}. */
97
+ export function areDepsEqual(a: DepsList, b: DepsList): boolean {
98
+ if (a === b) return true
99
+ if (a.length !== b.length) return false
100
+ for (let i = 0; i < a.length; i++) {
101
+ if (!isEqualDep(a[i], b[i])) return false
102
+ }
103
+ return true
104
+ }
105
+
106
+ /** Normalize a selector result into a deps list. */
107
+ export function toDeps(value: unknown): DepsList {
108
+ return Array.isArray(value) ? value : [value]
109
+ }
@@ -0,0 +1,281 @@
1
+ // react-fresh-key v0.1.0 — vendored copy (TypeScript).
2
+ // This file is yours now: edit it freely. Docs: https://www.npmjs.com/package/react-fresh-key
3
+ /*!
4
+ MIT License
5
+
6
+ Copyright (c) 2026 Rafael Buzatto de Campos
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+ */
26
+ import {
27
+ forwardRef,
28
+ useState,
29
+ type ComponentProps,
30
+ type ComponentPropsWithRef,
31
+ type ComponentType,
32
+ type ForwardRefExoticComponent,
33
+ type JSX,
34
+ type LazyExoticComponent,
35
+ type MemoExoticComponent,
36
+ } from 'react'
37
+ import { areDepsEqual, shallowEqual, toDeps, type DepsList } from './utils'
38
+
39
+ /**
40
+ * What to watch for remounting:
41
+ *
42
+ * - `['userId', 'mode']` — prop names; remount when any of them changes.
43
+ * - `(props) => props.user.id` — selector; remount when the returned value
44
+ * (or any element, if it returns an array) changes. Same as `{ select }`.
45
+ * - `{ select: (props) => value | deps[] }` — explicit selector form.
46
+ * - `{ when: (prev, next) => boolean }` — predicate over previous/next props;
47
+ * remount when it returns `true`.
48
+ *
49
+ * Predicates are always explicit (`{ when }`). A bare function is always a
50
+ * selector — there is no arity-based guessing.
51
+ *
52
+ * Selected values use `Object.is`, except plain objects and ordinary arrays
53
+ * are compared one level deep. Selectors and predicates run during render:
54
+ * keep them pure, because React may evaluate them more than once.
55
+ *
56
+ * Declared `defaultProps` are applied before callbacks, including through
57
+ * `memo`. Inner defaults behind `lazy` and JavaScript parameter defaults
58
+ * cannot be resolved by the wrapper; handle any optional props accordingly.
59
+ */
60
+ export type WatchSpec<P extends object> =
61
+ | ReadonlyArray<keyof P>
62
+ | ((props: P) => unknown)
63
+ | {
64
+ /**
65
+ * Pure selector of the incoming props. Return one watched value or an
66
+ * array of dependencies; remount when any selected dependency changes.
67
+ *
68
+ * @param props Incoming props with available `defaultProps` applied.
69
+ */
70
+ select: (props: P) => unknown
71
+ }
72
+ | {
73
+ /**
74
+ * Pure predicate called when props differ shallowly (`Object.is` per
75
+ * prop). Return `true` to remount. The snapshot advances on a detected
76
+ * change even when this returns `false`.
77
+ *
78
+ * @param prev Previous tracked props, not necessarily those at the last remount.
79
+ * @param next Incoming props. Available `defaultProps` are applied to both arguments.
80
+ */
81
+ when: (prev: P, next: P) => boolean
82
+ }
83
+
84
+ /**
85
+ * `true` when `C` is a `lazy` component, possibly wrapped in any number of
86
+ * `memo` layers. Mirrors the runtime `unwrapMemo`, which also looks through
87
+ * every memo layer and then stops at a lazy object it cannot open.
88
+ */
89
+ type ContainsLazy<C> =
90
+ C extends LazyExoticComponent<infer _Component>
91
+ ? true
92
+ : C extends MemoExoticComponent<infer T>
93
+ ? ContainsLazy<T>
94
+ : false
95
+
96
+ /**
97
+ * The props the wrapper accepts: whatever React's JSX checker would accept
98
+ * for the original component (so props covered by `defaultProps` stay
99
+ * optional — through `memo` too), with the ref type inferred from the
100
+ * original.
101
+ *
102
+ * `lazy` is the one exception, at any depth of `memo` nesting: its inner
103
+ * component's defaults are unknowable until the module loads, so the wrapper
104
+ * requires those props as declared. That keeps the types truthful about what
105
+ * selectors and predicates receive.
106
+ */
107
+ // biome-ignore lint/suspicious/noExplicitAny: This component constraint accepts required props; the concrete C preserves their types.
108
+ export type RemountedProps<C extends ComponentType<any>> =
109
+ ContainsLazy<C> extends true
110
+ ? ComponentPropsWithRef<C>
111
+ : JSX.LibraryManagedAttributes<C, ComponentPropsWithRef<C>>
112
+
113
+ type NormalizedWatch<P extends object> =
114
+ | { mode: 'select'; select: (props: P) => DepsList }
115
+ | { mode: 'when'; when: (prev: P, next: P) => boolean }
116
+
117
+ function normalizeWatch<P extends object>(watch: WatchSpec<P>): NormalizedWatch<P> {
118
+ if (Array.isArray(watch)) {
119
+ const names = watch as ReadonlyArray<keyof P>
120
+ return { mode: 'select', select: (props) => names.map((n) => props[n]) }
121
+ }
122
+ if (typeof watch === 'function') {
123
+ if (watch.length >= 2) {
124
+ throw new Error(
125
+ 'react-fresh-key: a bare function passed to withRemount is a selector `(props) => value`. ' +
126
+ 'For a predicate `(prev, next) => boolean`, pass `{ when: fn }` instead.'
127
+ )
128
+ }
129
+ const select = watch as (props: P) => unknown
130
+ return { mode: 'select', select: (props) => toDeps(select(props)) }
131
+ }
132
+ if (watch && typeof watch === 'object') {
133
+ if ('select' in watch && typeof watch.select === 'function') {
134
+ const select = watch.select
135
+ return { mode: 'select', select: (props) => toDeps(select(props)) }
136
+ }
137
+ if ('when' in watch && typeof watch.when === 'function') {
138
+ return { mode: 'when', when: watch.when }
139
+ }
140
+ }
141
+ throw new Error(
142
+ 'react-fresh-key: invalid watch spec. Expected a prop-name array, a selector function, { select }, or { when }.'
143
+ )
144
+ }
145
+
146
+ const REACT_MEMO_TYPE = Symbol.for('react.memo')
147
+
148
+ /**
149
+ * `memo(C)` is an object `{ $$typeof, type: C }`; React applies the *inner*
150
+ * component's `defaultProps` when it renders, and `JSX.LibraryManagedAttributes`
151
+ * unwraps `memo` the same way. Mirror both so types and runtime agree.
152
+ * (`lazy` cannot be unwrapped before it loads; see {@link RemountedProps}.)
153
+ */
154
+ function unwrapMemo(Component: unknown): unknown {
155
+ let current = Component
156
+ while (
157
+ current !== null &&
158
+ typeof current === 'object' &&
159
+ (current as { $$typeof?: unknown }).$$typeof === REACT_MEMO_TYPE
160
+ ) {
161
+ current = (current as { type: unknown }).type
162
+ }
163
+ return current
164
+ }
165
+
166
+ /**
167
+ * Apply `defaultProps` the way React does (a default fills in only when the
168
+ * prop is `undefined`). Returns the same object when nothing needs filling.
169
+ */
170
+ function makeDefaultsResolver<P extends object>(Component: unknown): (props: P) => P {
171
+ const defaults = (unwrapMemo(Component) as { defaultProps?: Record<string, unknown> } | null)
172
+ ?.defaultProps
173
+ if (!defaults) return (props) => props
174
+ const keys = Object.keys(defaults)
175
+ return (props) => {
176
+ let resolved: Record<string, unknown> | null = null
177
+ for (const key of keys) {
178
+ if ((props as Record<string, unknown>)[key] === undefined) {
179
+ resolved ??= { ...(props as Record<string, unknown>) }
180
+ resolved[key] = defaults[key]
181
+ }
182
+ }
183
+ return (resolved ?? props) as P
184
+ }
185
+ }
186
+
187
+ interface WatchState<P> {
188
+ /** The raw props object the current snapshot was taken from (the anchor). */
189
+ props: P
190
+ /** Selected deps (select mode only). */
191
+ deps: DepsList | null
192
+ key: number
193
+ }
194
+
195
+ /**
196
+ * Single-useState implementation so the hook order is identical for both
197
+ * modes, with the raw `props` object as the convergence anchor: after a
198
+ * render-phase setState, React re-invokes the component with the *same*
199
+ * props object, so the comparison branch is skipped on that second pass.
200
+ * That makes the derivation converge even when a selector returns a fresh
201
+ * object on every call (it remounts once per props change instead of looping).
202
+ */
203
+ function useWatchKey<P extends object>(
204
+ props: P,
205
+ spec: NormalizedWatch<P>,
206
+ resolve: (props: P) => P
207
+ ): number {
208
+ const [state, setState] = useState<WatchState<P>>(() => ({
209
+ props,
210
+ deps: spec.mode === 'select' ? spec.select(resolve(props)) : null,
211
+ key: 0,
212
+ }))
213
+
214
+ if (state.props !== props) {
215
+ if (spec.mode === 'select') {
216
+ const deps = spec.select(resolve(props))
217
+ if (!areDepsEqual(state.deps as DepsList, deps)) {
218
+ const key = state.key + 1
219
+ setState({ props, deps, key })
220
+ return key
221
+ }
222
+ // Unchanged: leave the snapshot alone (no extra render pass).
223
+ } else if (!shallowEqual(state.props, props)) {
224
+ // Only consult the predicate when props actually changed (shallow),
225
+ // so parent re-renders with identical props stay single-pass.
226
+ const key = spec.when(resolve(state.props), resolve(props)) ? state.key + 1 : state.key
227
+ setState({ props, deps: null, key })
228
+ return key
229
+ }
230
+ }
231
+
232
+ return state.key
233
+ }
234
+
235
+ /**
236
+ * Wrap a component with a remount policy declared *at the definition site*,
237
+ * so call sites don't need to know which props warrant a fresh mount:
238
+ *
239
+ * ```tsx
240
+ * // Profile.tsx
241
+ * function Profile({ userId }: ProfileProps) { ... }
242
+ * export default withRemount(Profile, ['userId'])
243
+ *
244
+ * // Anywhere else — no key juggling required:
245
+ * <Profile userId={id} />
246
+ * ```
247
+ *
248
+ * Parents can still override identity the normal way with their own `key`.
249
+ * Refs are forwarded, and the ref type is inferred from the wrapped
250
+ * component (a `forwardRef<HTMLInputElement, …>` component yields a wrapper
251
+ * that accepts `Ref<HTMLInputElement>` and nothing else). Props covered by
252
+ * `defaultProps` stay optional, except behind `lazy`; see {@link RemountedProps}.
253
+ *
254
+ * Call this once outside rendering. Creating a wrapper during render creates
255
+ * a new component type each time and discards its state. Selectors and
256
+ * predicates must be pure; they run during render and may be evaluated again.
257
+ *
258
+ * @param Component Component whose entire subtree remounts when the rule matches.
259
+ * @param watch Prop names, a selector, `{ select }`, or `{ when }`; see {@link WatchSpec}.
260
+ * @returns A component accepting the original props and supported ref type.
261
+ * @throws If the watch specification is invalid; pass predicates as `{ when }`.
262
+ */
263
+ // biome-ignore lint/suspicious/noExplicitAny: This component constraint accepts required props; the concrete C preserves their types.
264
+ export function withRemount<C extends ComponentType<any>>(
265
+ Component: C,
266
+ watch: WatchSpec<ComponentProps<C>>
267
+ ): ForwardRefExoticComponent<RemountedProps<C>> {
268
+ const spec = normalizeWatch(watch)
269
+ const resolve = makeDefaultsResolver<ComponentProps<C>>(Component)
270
+ const Inner = Component as ComponentType<ComponentProps<C>>
271
+
272
+ const Wrapped = forwardRef<unknown, ComponentProps<C>>((props, ref) => {
273
+ const key = useWatchKey(props as ComponentProps<C>, spec, resolve)
274
+ return <Inner key={key} ref={ref} {...(props as ComponentProps<C>)} />
275
+ })
276
+
277
+ const name = Component.displayName ?? Component.name ?? 'Component'
278
+ Wrapped.displayName = `withRemount(${name})`
279
+
280
+ return Wrapped as unknown as ForwardRefExoticComponent<RemountedProps<C>>
281
+ }