Skip to content

Commit 8544ea4

Browse files
bowheartclaude
andcommitted
feat(atoms): replace arrays instead of deep merging in mutate shorthand
Arrays in the `.mutate` shorthand now fully replace instead of deep merging by index. The previous behavior was surprising - e.g. `signal.mutate({ arr: [1, 2] })` on `[10, 20, 30]` would produce `[1, 2, 30]`. Now it produces `[1, 2]`. Use the callback form for index-level updates: `signal.mutate(s => { s.arr[2] = 30 })`. Also fixes pre-existing type errors by loosening `AtomApiGenerics` Signal constraint from `Signal` to `AnySignal`, and consolidates `RecursivePartialWithArrayPlucking` into `RecursivePartial` (with an array guard added to both core and atoms). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d49d608 commit 8544ea4

File tree

7 files changed

+32
-48
lines changed

7 files changed

+32
-48
lines changed

packages/atoms/src/classes/proxies.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
Transaction,
33
MutatableTypes,
4-
RecursivePartialWithArrayPlucking,
4+
RecursivePartial,
55
} from '../types/index'
66

77
/**
@@ -333,7 +333,7 @@ const mutateSet = (prevVal: Set<any>, newVal: Set<any>) => {
333333
*/
334334
export function recursivelyMutate<
335335
State extends any[] | Record<string, any> | Set<any>
336-
>(state: State, update: RecursivePartialWithArrayPlucking<State>) {
336+
>(state: State, update: RecursivePartial<State>) {
337337
// the `.mutate` shorthand only supports same-type overwrites (no set ->
338338
// object, array -> set, etc. type changes). So we assume `update` is a set
339339
// here.
@@ -350,9 +350,8 @@ export function recursivelyMutate<
350350

351351
if (
352352
isPlainObject(val)
353-
? isPlainObject(prevVal) || Array.isArray(prevVal)
354-
: (Array.isArray(prevVal) && Array.isArray(val)) ||
355-
(prevVal instanceof Set && val instanceof Set)
353+
? isPlainObject(prevVal)
354+
: prevVal instanceof Set && val instanceof Set
356355
) {
357356
recursivelyMutate(prevVal as any, val)
358357
} else {

packages/atoms/src/injectors/injectPromise.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ export const injectPromise: {
208208
promise: Promise<Data>
209209
})
210210

211-
const dataSignal = injectSignal(initialData, { reactive: false }) as Signal<{
211+
const dataSignal = injectSignal(initialData, {
212+
reactive: false,
213+
}) as unknown as Signal<{
212214
Events: EventMap
213215
Params: undefined
214216
ResolvedState: Data

packages/atoms/src/types/atoms.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export type AtomApiGenerics = Pick<
7474
AtomGenerics,
7575
'Exports' | 'Promise' | 'State'
7676
> & {
77-
Signal: Signal | undefined
77+
Signal: AnySignal | undefined
7878
}
7979

8080
export type AtomGenericsToAtomApiGenerics<

packages/atoms/src/types/events.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ListenerConfig,
88
NodeGenerics,
99
Prettify,
10-
RecursivePartialWithArrayPlucking,
10+
RecursivePartial,
1111
} from './index'
1212

1313
export type CatchAllListener<G extends NodeGenerics> = (
@@ -159,8 +159,8 @@ export type ListenableEvents<G extends NodeGenerics = AnyNodeGenerics> =
159159
Prettify<G['Events'] & ExplicitEvents & ImplicitEvents<G>>
160160

161161
export type Mutatable<State> =
162-
| RecursivePartialWithArrayPlucking<State>
163-
| ((state: State) => void | RecursivePartialWithArrayPlucking<State>)
162+
| RecursivePartial<State>
163+
| ((state: State) => void | RecursivePartial<State>)
164164

165165
export type MutatableTypes = any[] | Record<string, any> | Set<any>
166166

packages/atoms/src/types/index.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { AtomApi } from '../classes/AtomApi'
33
import { Ecosystem } from '../classes/Ecosystem'
44
import { ZeduxNode } from '../classes/ZeduxNode'
55
import { SelectorInstance } from '../classes/SelectorInstance'
6-
import { Signal } from '../classes/Signal'
76
import {
87
InternalEvaluationType,
98
InternalLifecycleStatus,
@@ -86,7 +85,7 @@ export type SelectorTemplate<State = any, Params extends any[] = any> =
8685

8786
export type AtomStateFactory<
8887
G extends Pick<AtomGenerics, 'Exports' | 'Params' | 'Promise' | 'State'> & {
89-
Signal: Signal | undefined
88+
Signal: AnySignal | undefined
9089
}
9190
> = (
9291
...params: G['Params']
@@ -99,7 +98,7 @@ export type AtomTuple<A extends AnyAtomTemplate> = [A, ParamsOf<A>]
9998

10099
export type AtomValueOrFactory<
101100
G extends Pick<AtomGenerics, 'Exports' | 'Params' | 'Promise' | 'State'> & {
102-
Signal: Signal | undefined
101+
Signal: AnySignal | undefined
103102
}
104103
> = AtomStateFactory<G> | G['State']
105104

@@ -492,12 +491,6 @@ export type RecursivePartial<T> = T extends Record<string, any>
492491
? { [P in keyof T]?: RecursivePartial<T[P]> }
493492
: T
494493

495-
export type RecursivePartialWithArrayPlucking<T> = T extends any[]
496-
? { [K in number]?: RecursivePartialWithArrayPlucking<T[number]> }
497-
: T extends Record<string, any>
498-
? { [P in keyof T]?: RecursivePartialWithArrayPlucking<T[P]> }
499-
: T
500-
501494
export type Ref<T = any> = MutableRefObject<T>
502495

503496
export interface RefObject<T = any> {

packages/react/test/integrations/__snapshots__/proxies.test.tsx.snap

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,11 @@ exports[`proxies array operations 1`] = `
88
"k": [
99
"0",
1010
"a",
11-
"0",
1211
],
13-
"v": 11,
14-
},
15-
{
16-
"k": [
17-
"0",
18-
"a",
19-
"1",
12+
"v": [
13+
11,
14+
33,
2015
],
21-
"v": 33,
2216
},
2317
],
2418
[

packages/react/test/integrations/proxies.test.tsx

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe('proxies', () => {
259259
],
260260
[
261261
[
262-
{ k: ['arr', '1'], v: 'b' },
262+
{ k: 'arr', v: ['a', 'b'] },
263263
{ k: ['set', 'b'], v: undefined },
264264
],
265265
{
@@ -269,7 +269,7 @@ describe('proxies', () => {
269269
],
270270
[
271271
[
272-
{ k: ['arr', '2'], v: 'c' },
272+
{ k: 'arr', v: ['a', 'b', 'c'] },
273273
{ k: ['set', 'c'], v: undefined },
274274
],
275275
{
@@ -312,7 +312,10 @@ describe('proxies', () => {
312312
})
313313

314314
signal.mutate(draft => [draft[0] + 1, draft[1] + 1])
315-
signal.mutate({ 2: 30, 3: 40 })
315+
signal.mutate(state => {
316+
state[2] = 30
317+
state[3] = 40
318+
})
316319

317320
expect(signal.get()).toEqual([2, 3, 30, 40, 5])
318321
})
@@ -373,18 +376,11 @@ describe('proxies', () => {
373376
calls.push(transactions)
374377
})
375378

376-
// Array shorthand should work and only create transactions for changed indices
377-
signal.mutate({ arr: { 2: 30 } })
379+
// Array shorthand fully replaces the array instead of deep merging
378380
signal.mutate({ arr: [11, 21] })
379381

380-
expect(signal.get()).toEqual({ arr: [11, 21, 30] })
381-
expect(calls).toEqual([
382-
[{ k: ['arr', '2'], v: 30 }],
383-
[
384-
{ k: ['arr', '0'], v: 11 },
385-
{ k: ['arr', '1'], v: 21 },
386-
],
387-
])
382+
expect(signal.get()).toEqual({ arr: [11, 21] })
383+
expect(calls).toEqual([[{ k: 'arr', v: [11, 21] }]])
388384
})
389385
})
390386

@@ -615,13 +611,13 @@ describe('proxies', () => {
615611
expect(result).not.toBeInstanceOf(Config)
616612
})
617613

618-
test('plain object can update specific array indices via shorthand', () => {
614+
test('plain object replaces array via shorthand', () => {
619615
const signal = ecosystem.signal<any>({ data: [1, 2, 3] })
620616

621-
signal.mutate({ data: { 0: 10 } })
617+
signal.mutate({ data: { a: 10 } })
622618

623-
// plain object recurses into array, updating only index 0
624-
expect(signal.get().data).toEqual([10, 2, 3])
619+
// plain object fully replaces the array
620+
expect(signal.get().data).toEqual({ a: 10 })
625621
})
626622

627623
test('replacing a plain object with an array assigns directly', () => {
@@ -659,7 +655,7 @@ describe('proxies', () => {
659655
expect(signal.get().nested).toEqual({ a: 100, b: 2 })
660656

661657
signal.mutate({ arr: [11] })
662-
expect(signal.get().arr).toEqual([11, 20])
658+
expect(signal.get().arr).toEqual([11])
663659

664660
signal.mutate({ set: new Set([2, 3]) })
665661
expect(signal.get().set).toEqual(new Set([2, 3]))
@@ -764,8 +760,8 @@ describe('proxies', () => {
764760
// class instance: directly assigned
765761
expect(signal.get().tag).toBeInstanceOf(Tag)
766762
expect(signal.get().tag.name).toBe('user')
767-
// array: recursively merged (index 1 preserved)
768-
expect(signal.get().scores).toEqual([150, 200])
763+
// array: fully replaced (no deep merge)
764+
expect(signal.get().scores).toEqual([150])
769765
})
770766

771767
test('proxy does not wrap non-plain objects when accessed via getter', () => {

0 commit comments

Comments
 (0)