@playfast/reform-forms 1.3.0 → 1.4.1

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,24 @@
1
+ import { Option } from 'effect';
2
+ import type { FormState } from './formTypes.js';
3
+ type IndexMap = (index: number) => Option.Option<number>;
4
+ interface ReindexMetadataInput<Values> {
5
+ readonly state: FormState<Values>;
6
+ readonly path: string;
7
+ readonly moveIndex: IndexMap;
8
+ }
9
+ export declare const reindexMetadata: <Values>({ state, path, moveIndex, }: ReindexMetadataInput<Values>) => Pick<FormState<Values>, "touched" | "errors"> & {
10
+ readonly nestedKeys: Readonly<Record<string, ReadonlyArray<string>>>;
11
+ };
12
+ export declare const removedIndexMap: (removed: number) => IndexMap;
13
+ interface MoveRange {
14
+ readonly from: number;
15
+ readonly to: number;
16
+ }
17
+ export declare const movedIndexMap: ({ from, to }: MoveRange) => IndexMap;
18
+ interface SwapPair {
19
+ readonly first: number;
20
+ readonly second: number;
21
+ }
22
+ export declare const swappedIndexMap: ({ first, second }: SwapPair) => IndexMap;
23
+ export {};
24
+ //# sourceMappingURL=formReindex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formReindex.d.ts","sourceRoot":"","sources":["../src/formReindex.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAU,MAAM,QAAQ,CAAA;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAO5C,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;AAoDxD,UAAU,oBAAoB,CAAC,MAAM;IACnC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,GAAI,MAAM,EAAE,6BAIrC,oBAAoB,CAAC,MAAM,CAAC,KAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,GAAG;IAChF,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;CAKpE,CAAA;AAEF,eAAO,MAAM,eAAe,GACzB,SAAS,MAAM,KAAG,QAEmE,CAAA;AAExF,UAAU,SAAS;IACjB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,eAAO,MAAM,aAAa,GACvB,cAAc,SAAS,KAAG,QAS1B,CAAA;AAEH,UAAU,QAAQ;IAChB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,eAAO,MAAM,eAAe,GACzB,mBAAmB,QAAQ,KAAG,QAQ5B,CAAA"}
@@ -0,0 +1,42 @@
1
+ import { Match, Option, Record } from 'effect';
2
+ // `members[2].tags` under path `members` → index 2, suffix `.tags`.
3
+ const indexedSegment = /^\[(\d+)\]/;
4
+ const reindexedKey = ({ key, path, moveIndex }) => {
5
+ const rest = key.slice(path.length);
6
+ return Option.match(Option.fromNullable(indexedSegment.exec(rest)), {
7
+ onNone: () => Option.some(key),
8
+ onSome: (found) => {
9
+ const index = Number(found[1]);
10
+ if (!Number.isInteger(index)) {
11
+ return Option.some(key);
12
+ }
13
+ const suffix = rest.slice(found[0].length);
14
+ return Option.map(moveIndex(index), (next) => `${path}[${next}]${suffix}`);
15
+ },
16
+ });
17
+ };
18
+ const reindexPaths = ({ entries, path, moveIndex, }) => {
19
+ const prefix = `${path}[`;
20
+ const moved = ([key, entry]) => Option.match(reindexedKey({ key, path, moveIndex }), {
21
+ onNone: () => [],
22
+ onSome: (nextKey) => [[nextKey, entry]],
23
+ });
24
+ return Record.fromEntries(Record.toEntries(entries).flatMap((pair) => pair[0].startsWith(prefix) ? moved(pair) : [pair]));
25
+ };
26
+ export const reindexMetadata = ({ state, path, moveIndex, }) => ({
27
+ touched: reindexPaths({ entries: state.touched, path, moveIndex }),
28
+ errors: reindexPaths({ entries: state.errors, path, moveIndex }),
29
+ nestedKeys: reindexPaths({ entries: state.arrayKeys, path, moveIndex }),
30
+ });
31
+ export const removedIndexMap = (removed) => (index) => index === removed ? Option.none() : Option.some(index > removed ? index - 1 : index);
32
+ export const movedIndexMap = ({ from, to }) => (index) => {
33
+ if (index === from) {
34
+ return Option.some(to);
35
+ }
36
+ if (from < to) {
37
+ return Option.some(index > from && index <= to ? index - 1 : index);
38
+ }
39
+ return Option.some(index >= to && index < from ? index + 1 : index);
40
+ };
41
+ export const swappedIndexMap = ({ first, second }) => (index) => Option.some(Match.value(index).pipe(Match.when(first, () => second), Match.when(second, () => first), Match.orElse(() => index)));
42
+ //# sourceMappingURL=formReindex.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"formReindex.js","sourceRoot":"","sources":["../src/formReindex.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"formState.d.ts","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAK5C,eAAO,MAAM,YAAY,GAAI,QAAQ,OAAO,EAAE,aAAS,KAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAmB7F,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,SAAS,MAAM,KAAG,SAAS,CAAC,MAAM,CAUrE,CAAA;AAEF,eAAO,MAAM,WAAW,GACtB,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC1C,MAAM,MAAM,KACX,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAmC,CAAA;AAEtE,UAAU,WAAW;IACnB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAOD,UAAU,SAAS,CAAC,MAAM;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,UAAU,YAAY,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,eAAe,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAQD,eAAO,MAAM,YAAY,GAAI,OAAO,WAAW,KAAG,aAAa,CAAC,OAAO,CAGtE,CAAA;AAYD,eAAO,MAAM,SAAS,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CACM,CAAA;AAEtF,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAUlF,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,eAAe,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAgBrF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAajF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CA2BjF,CAAA"}
1
+ {"version":3,"file":"formState.d.ts","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,eAAO,MAAM,YAAY,GAAI,QAAQ,OAAO,EAAE,aAAS,KAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAmB7F,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,SAAS,MAAM,KAAG,SAAS,CAAC,MAAM,CAUrE,CAAA;AAEF,eAAO,MAAM,WAAW,GACtB,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC1C,MAAM,MAAM,KACX,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAmC,CAAA;AAEtE,UAAU,WAAW;IACnB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAOD,UAAU,SAAS,CAAC,MAAM;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,UAAU,YAAY,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,eAAe,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAQD,eAAO,MAAM,YAAY,GAAI,OAAO,WAAW,KAAG,aAAa,CAAC,OAAO,CAGtE,CAAA;AAyBD,eAAO,MAAM,SAAS,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAM9E,CAAA;AAEF,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAalF,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,eAAe,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAyBrF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CA4BjF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAqCjF,CAAA"}
package/dist/formState.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Function as Fn, Option, Record } from 'effect';
2
- import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue, } from './path.js';
2
+ import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue } from './path.js';
3
+ import { movedIndexMap, reindexMetadata, removedIndexMap, swappedIndexMap } from './formReindex.js';
3
4
  const arrayKeyCounter = { current: 0 };
4
5
  const makeArrayKey = () => `form-item-${arrayKeyCounter.current++}`;
5
6
  export const arrayKeysFor = (source, path = '') => {
@@ -42,16 +43,29 @@ const updateKeys = (state, path, transform) => {
42
43
  const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey());
43
44
  return { ...state.arrayKeys, [path]: transform(existing) };
44
45
  };
45
- export const setAtPath = (input) => withValues(input.state, setNestedValue(input.state.values, input.path, input.value));
46
+ // The subtree at `path` is being replaced, so every key describing a row inside it
47
+ // is about to describe nothing. Left behind, the next append folds over the stale
48
+ // array and hands a new row the identity of one the user discarded.
49
+ const withoutSubtree = (keys, path) => Record.fromEntries(Record.toEntries(keys).filter(([key]) => key !== path && !key.startsWith(`${path}[`) && !key.startsWith(`${path}.`)));
50
+ export const setAtPath = (input) => ({
51
+ ...withValues(input.state, setNestedValue(input.state.values, input.path, input.value)),
52
+ arrayKeys: {
53
+ ...withoutSubtree(input.state.arrayKeys, input.path),
54
+ ...arrayKeysFor(input.value, input.path),
55
+ },
56
+ });
46
57
  export const appendAtPath = (input) => {
47
58
  const elements = currentArray({ source: input.state.values, path: input.path });
48
59
  const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value]);
49
60
  return {
50
61
  ...withValues(input.state, nextValues),
51
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
52
- ...keys,
53
- makeArrayKey(),
54
- ]),
62
+ arrayKeys: {
63
+ ...updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
64
+ // Arrays nested inside the appended item need keys of their own — `initialState`
65
+ // seeds every array in the tree, and a nested list without them falls back to a
66
+ // positional key, so its rows change identity the first time one is touched.
67
+ ...arrayKeysFor(input.value, `${input.path}[${elements.length}]`),
68
+ },
55
69
  };
56
70
  };
57
71
  export const removeAtPath = (input) => {
@@ -60,9 +74,16 @@ export const removeAtPath = (input) => {
60
74
  return input.state;
61
75
  }
62
76
  const nextValues = setNestedValue(input.state.values, input.path, elements.filter((_, position) => position !== input.index));
77
+ const moved = reindexMetadata({
78
+ state: input.state,
79
+ path: input.path,
80
+ moveIndex: removedIndexMap(input.index),
81
+ });
63
82
  return {
64
83
  ...withValues(input.state, nextValues),
65
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => keys.filter((_, position) => position !== input.index)),
84
+ touched: moved.touched,
85
+ errors: moved.errors,
86
+ arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => keys.filter((_, position) => position !== input.index)),
66
87
  };
67
88
  };
68
89
  export const moveAtPath = (input) => {
@@ -72,9 +93,22 @@ export const moveAtPath = (input) => {
72
93
  return input.state;
73
94
  }
74
95
  const nextValues = setNestedValue(input.state.values, input.path, next);
96
+ const moved = reindexMetadata({
97
+ state: input.state,
98
+ path: input.path,
99
+ // `moveAt` accepts `to === elements.length` as "move it to the end", but a move
100
+ // keeps the length, so the item lands one slot short of that. The metadata has to
101
+ // follow the value, not the argument.
102
+ moveIndex: movedIndexMap({
103
+ from: input.from,
104
+ to: Math.min(input.to, elements.length - 1),
105
+ }),
106
+ });
75
107
  return {
76
108
  ...withValues(input.state, nextValues),
77
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
109
+ touched: moved.touched,
110
+ errors: moved.errors,
111
+ arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => moveAt(keys, input.from, input.to)),
78
112
  };
79
113
  };
80
114
  export const swapAtPath = (input) => {
@@ -88,9 +122,16 @@ export const swapAtPath = (input) => {
88
122
  }
89
123
  const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first]);
90
124
  const nextValues = setNestedValue(input.state.values, input.path, swapped);
125
+ const moved = reindexMetadata({
126
+ state: input.state,
127
+ path: input.path,
128
+ moveIndex: swappedIndexMap({ first: input.first, second: input.second }),
129
+ });
91
130
  return {
92
131
  ...withValues(input.state, nextValues),
93
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => replaceAt(replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()), input.second, keys[input.first] ?? makeArrayKey())),
132
+ touched: moved.touched,
133
+ errors: moved.errors,
134
+ arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => replaceAt(replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()), input.second, keys[input.first] ?? makeArrayKey())),
94
135
  };
95
136
  };
96
137
  //# sourceMappingURL=formState.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"formState.js","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"formState.js","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playfast/reform-forms",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Headless, schema-driven form state for reform — values, validation, field limitations, list operations, and submit flow, rendering nothing.",
5
5
  "keywords": [
6
6
  "effect",
@@ -0,0 +1,115 @@
1
+ import { Match, Option, Record } from 'effect'
2
+ import type { FormState } from './formTypes'
3
+
4
+ // Every per-field record — `touched`, `errors`, and the nested `arrayKeys` of a
5
+ // list inside an item — is addressed by a path that embeds the item's index.
6
+ // A structural edit therefore has to carry that metadata with the item, or the
7
+ // neighbour that slides into the vacated index inherits it: a removed row's
8
+ // touched flag, or a reordered row's validation error, landing on someone else.
9
+ type IndexMap = (index: number) => Option.Option<number>
10
+
11
+ interface ReindexInput {
12
+ readonly key: string
13
+ readonly path: string
14
+ readonly moveIndex: IndexMap
15
+ }
16
+
17
+ // `members[2].tags` under path `members` → index 2, suffix `.tags`.
18
+ const indexedSegment = /^\[(\d+)\]/
19
+
20
+ const reindexedKey = ({ key, path, moveIndex }: ReindexInput): Option.Option<string> => {
21
+ const rest = key.slice(path.length)
22
+ return Option.match(Option.fromNullable(indexedSegment.exec(rest)), {
23
+ onNone: () => Option.some(key),
24
+ onSome: (found) => {
25
+ const index = Number(found[1])
26
+ if (!Number.isInteger(index)) {
27
+ return Option.some(key)
28
+ }
29
+ const suffix = rest.slice(found[0].length)
30
+ return Option.map(moveIndex(index), (next) => `${path}[${next}]${suffix}`)
31
+ },
32
+ })
33
+ }
34
+
35
+ interface ReindexPathsInput<V> {
36
+ readonly entries: Readonly<Record<string, V>>
37
+ readonly path: string
38
+ readonly moveIndex: IndexMap
39
+ }
40
+
41
+ const reindexPaths = <V>({
42
+ entries,
43
+ path,
44
+ moveIndex,
45
+ }: ReindexPathsInput<V>): Readonly<Record<string, V>> => {
46
+ const prefix = `${path}[`
47
+ const moved = ([key, entry]: readonly [string, V]): ReadonlyArray<readonly [string, V]> =>
48
+ Option.match(reindexedKey({ key, path, moveIndex }), {
49
+ onNone: (): ReadonlyArray<readonly [string, V]> => [],
50
+ onSome: (nextKey) => [[nextKey, entry] as const],
51
+ })
52
+ return Record.fromEntries(
53
+ Record.toEntries(entries).flatMap((pair) =>
54
+ pair[0].startsWith(prefix) ? moved(pair) : [pair],
55
+ ),
56
+ )
57
+ }
58
+
59
+ // `arrayKeys[path]` itself is rewritten by `updateKeys`; only the entries nested
60
+ // under an item (`members[0].tags`) need moving, and those start with the prefix.
61
+ interface ReindexMetadataInput<Values> {
62
+ readonly state: FormState<Values>
63
+ readonly path: string
64
+ readonly moveIndex: IndexMap
65
+ }
66
+
67
+ export const reindexMetadata = <Values>({
68
+ state,
69
+ path,
70
+ moveIndex,
71
+ }: ReindexMetadataInput<Values>): Pick<FormState<Values>, 'touched' | 'errors'> & {
72
+ readonly nestedKeys: Readonly<Record<string, ReadonlyArray<string>>>
73
+ } => ({
74
+ touched: reindexPaths({ entries: state.touched, path, moveIndex }),
75
+ errors: reindexPaths({ entries: state.errors, path, moveIndex }),
76
+ nestedKeys: reindexPaths({ entries: state.arrayKeys, path, moveIndex }),
77
+ })
78
+
79
+ export const removedIndexMap =
80
+ (removed: number): IndexMap =>
81
+ (index) =>
82
+ index === removed ? Option.none() : Option.some(index > removed ? index - 1 : index)
83
+
84
+ interface MoveRange {
85
+ readonly from: number
86
+ readonly to: number
87
+ }
88
+
89
+ export const movedIndexMap =
90
+ ({ from, to }: MoveRange): IndexMap =>
91
+ (index) => {
92
+ if (index === from) {
93
+ return Option.some(to)
94
+ }
95
+ if (from < to) {
96
+ return Option.some(index > from && index <= to ? index - 1 : index)
97
+ }
98
+ return Option.some(index >= to && index < from ? index + 1 : index)
99
+ }
100
+
101
+ interface SwapPair {
102
+ readonly first: number
103
+ readonly second: number
104
+ }
105
+
106
+ export const swappedIndexMap =
107
+ ({ first, second }: SwapPair): IndexMap =>
108
+ (index) =>
109
+ Option.some(
110
+ Match.value(index).pipe(
111
+ Match.when(first, () => second),
112
+ Match.when(second, () => first),
113
+ Match.orElse(() => index),
114
+ ),
115
+ )
@@ -0,0 +1,179 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Function as Fn, Layer, Schema as S } from 'effect'
3
+ import { Engine } from '@playfast/reform'
4
+ import * as Form from './form'
5
+ import type { ArrayBinding, FieldBinding, FormView } from './formTypes'
6
+
7
+ // Same polling idiom as the neighbouring suites: never wait a fixed duration.
8
+ const waitUntil = <A, R>(
9
+ read: Effect.Effect<A, never, R>,
10
+ pred: (value: A) => boolean,
11
+ rounds = 100,
12
+ ): Effect.Effect<A, never, R> =>
13
+ Effect.flatMap(read, (value) =>
14
+ pred(value) || rounds <= 0
15
+ ? Effect.succeed(value)
16
+ : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
17
+ )
18
+
19
+ const RosterSchema = S.Struct({
20
+ members: S.Array(S.Struct({ name: S.String, tags: S.Array(S.String) })),
21
+ })
22
+
23
+ type RosterValues = S.Schema.Encoded<typeof RosterSchema>
24
+
25
+ class AppendKeysForm extends Form.make('FormsHuntAppendKeysForm', { schema: RosterSchema }) {}
26
+
27
+ const rosterLayer = Form.live(AppendKeysForm, {
28
+ // `ada` comes from `initial`, so `initialState` seeds stable keys for her nested
29
+ // `tags` list. Every other member in these tests arrives through `append`.
30
+ initial: { members: [{ name: 'ada', tags: ['a1', 'a2'] }] },
31
+ }).pipe(Layer.provideMerge(Engine))
32
+
33
+ // A list nested inside an array item is addressed by an indexed runtime path
34
+ // (`members[1].tags`). `ArrayPath<Values>` cannot spell one, so every renderer
35
+ // reaches it the way `@playfast/reform-forms-react` does — through the erased
36
+ // `RuntimeFormView` shape whose `array` takes a plain string.
37
+ const nestedArray = (view: FormView<RosterValues>, path: string): ArrayBinding<string> => {
38
+ const runtimeArray: (candidate: string) => ArrayBinding<string> = Fn.unsafeCoerce(view.array)
39
+ return runtimeArray(path)
40
+ }
41
+
42
+ const nestedField = (
43
+ view: FormView<RosterValues>,
44
+ path: string,
45
+ ): FieldBinding<ReadonlyArray<string>> => {
46
+ const runtimeField: (candidate: string) => FieldBinding<ReadonlyArray<string>> = Fn.unsafeCoerce(
47
+ view.field,
48
+ )
49
+ return runtimeField(path)
50
+ }
51
+
52
+ const keysOf = (view: FormView<RosterValues>, path: string): ReadonlyArray<string> =>
53
+ nestedArray(view, path).items.map((item) => item.key)
54
+
55
+ const tagCount = (values: RosterValues, index: number): number =>
56
+ values.members[index]?.tags.length ?? 0
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // `appendAtPath` mints one key for the row it adds to `arrayKeys[path]`, but it
60
+ // never derives keys for the arrays nested INSIDE the appended value the way
61
+ // `initialState` does via `arrayKeysFor`. Those nested lists then fall through
62
+ // `formView.array`'s positional fallback (`${path}-${index}`) until some later
63
+ // edit lazily mints real keys — at which point every existing row of that list
64
+ // changes identity and a renderer remounts rows the user never touched.
65
+ // ---------------------------------------------------------------------------
66
+
67
+ it.scopedLive('a nested list inside an appended item keeps its row keys when a row is added', () =>
68
+ Effect.gen(function* () {
69
+ const initial = yield* Form.view(AppendKeysForm)
70
+ initial.array('members').append({ name: 'zoe', tags: ['z1', 'z2'] })
71
+ const seeded = yield* waitUntil(
72
+ Form.view(AppendKeysForm),
73
+ (view) => view.values.members.length === 2,
74
+ )
75
+
76
+ const adaBefore = keysOf(seeded, 'members[0].tags')
77
+ const zoeBefore = keysOf(seeded, 'members[1].tags')
78
+ expect(adaBefore.length).toBe(2)
79
+ expect(zoeBefore.length).toBe(2)
80
+
81
+ nestedArray(seeded, 'members[0].tags').append('a3')
82
+ const adaGrown = yield* waitUntil(
83
+ Form.view(AppendKeysForm),
84
+ (view) => tagCount(view.values, 0) === 3,
85
+ )
86
+ nestedArray(adaGrown, 'members[1].tags').append('z3')
87
+ const grown = yield* waitUntil(
88
+ Form.view(AppendKeysForm),
89
+ (view) => tagCount(view.values, 1) === 3,
90
+ )
91
+
92
+ // CONTROL — appending a row to ada's list leaves the two existing rows' keys
93
+ // untouched, which is what "stable item keys" means.
94
+ expect(keysOf(grown, 'members[0].tags').slice(0, 2)).toEqual(adaBefore)
95
+ // SUBJECT — the same operation on zoe's list must be just as stable.
96
+ expect(keysOf(grown, 'members[1].tags').slice(0, 2)).toEqual(zoeBefore)
97
+
98
+ // Root cause, stated directly: ada's rows carry minted keys, zoe's carry the
99
+ // positional `${path}-${index}` fallback because `append` seeded none.
100
+ expect(adaBefore.every((key) => key.startsWith('form-item-'))).toBe(true)
101
+ expect(zoeBefore.every((key) => key.startsWith('form-item-'))).toBe(true)
102
+ }).pipe(Effect.provide(rosterLayer)),
103
+ )
104
+
105
+ it.scopedLive('a nested list inside an appended item keeps its row keys across a parent move', () =>
106
+ Effect.gen(function* () {
107
+ const initial = yield* Form.view(AppendKeysForm)
108
+ initial.array('members').append({ name: 'zoe', tags: ['z1', 'z2'] })
109
+ const seeded = yield* waitUntil(
110
+ Form.view(AppendKeysForm),
111
+ (view) => view.values.members.length === 2,
112
+ )
113
+
114
+ const adaBefore = keysOf(seeded, 'members[0].tags')
115
+ const zoeBefore = keysOf(seeded, 'members[1].tags')
116
+
117
+ // Reorder the OUTER list; neither nested row was edited.
118
+ seeded.array('members').move(1, 0)
119
+ const moved = yield* waitUntil(
120
+ Form.view(AppendKeysForm),
121
+ (view) => view.values.members[0]?.name === 'zoe',
122
+ )
123
+ expect(moved.values.members.map((member) => member.name)).toEqual(['zoe', 'ada'])
124
+
125
+ // CONTROL — ada's nested keys travel with her to index 1 (reindexMetadata).
126
+ expect(keysOf(moved, 'members[1].tags')).toEqual(adaBefore)
127
+ // SUBJECT — zoe's nested rows must likewise keep the identity they had at
128
+ // index 1 now that she sits at index 0.
129
+ expect(keysOf(moved, 'members[0].tags')).toEqual(zoeBefore)
130
+ }).pipe(Effect.provide(rosterLayer)),
131
+ )
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // `setAtPath` replaces the value at a path and never touches `arrayKeys`, so the
135
+ // keys describing the rows it just discarded stay behind. The next mutation folds
136
+ // over that stale array, and a freshly appended row inherits the identity of a row
137
+ // the user removed.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ class SetKeysForm extends Form.make('FormsHuntSetKeysForm', { schema: RosterSchema }) {}
141
+
142
+ const setKeysLayer = Form.live(SetKeysForm, {
143
+ initial: { members: [{ name: 'ada', tags: ['a1', 'a2', 'a3'] }] },
144
+ }).pipe(Layer.provideMerge(Engine))
145
+
146
+ it.scopedLive('replacing an array wholesale does not leave a removed rowid behind', () =>
147
+ Effect.gen(function* () {
148
+ const initial = yield* Form.view(SetKeysForm)
149
+ const seeded = keysOf(initial, 'members[0].tags')
150
+ expect(seeded.length).toBe(3)
151
+
152
+ // CONTROL — appending to the list as seeded gives the new row an identity none
153
+ // of the existing rows holds.
154
+ nestedArray(initial, 'members[0].tags').append('a4')
155
+ const grown = yield* waitUntil(Form.view(SetKeysForm), (view) => tagCount(view.values, 0) === 4)
156
+ const grownKeys = keysOf(grown, 'members[0].tags')
157
+ expect(grownKeys.slice(0, 3)).toEqual(seeded)
158
+ expect(seeded).not.toContain(grownKeys[3])
159
+
160
+ // SUBJECT — replace the whole list with one row, then append. The three keys
161
+ // minted for the discarded rows are still in `arrayKeys`, so the appended row
162
+ // reads back the key of a row that no longer exists.
163
+ nestedField(grown, 'members[0].tags').set(['b1'])
164
+ const shrunk = yield* waitUntil(
165
+ Form.view(SetKeysForm),
166
+ (view) => tagCount(view.values, 0) === 1,
167
+ )
168
+ nestedArray(shrunk, 'members[0].tags').append('b2')
169
+ const regrown = yield* waitUntil(
170
+ Form.view(SetKeysForm),
171
+ (view) => tagCount(view.values, 0) === 2,
172
+ )
173
+
174
+ const regrownKeys = keysOf(regrown, 'members[0].tags')
175
+ expect(regrownKeys).toHaveLength(2)
176
+ expect(new Set(regrownKeys).size).toBe(2)
177
+ expect(seeded.slice(1)).not.toContain(regrownKeys[1])
178
+ }).pipe(Effect.provide(setKeysLayer)),
179
+ )
@@ -0,0 +1,198 @@
1
+ import { expect, it } from '@effect/vitest'
2
+ import { Effect, Exit, Layer, Option, Schema as S } from 'effect'
3
+ import { Engine, Event } from '@playfast/reform'
4
+ import * as Form from './form'
5
+ import * as FormState from './formState'
6
+ import type { FormState as FormStateShape } from './formTypes'
7
+ import { expect as proofExpect } from '../../proof/src/assert'
8
+
9
+ // Same polling idiom as form.test.ts: never wait a fixed duration, yield until the
10
+ // condition holds.
11
+ const waitUntil = <A, R>(
12
+ read: Effect.Effect<A, never, R>,
13
+ pred: (value: A) => boolean,
14
+ rounds = 100,
15
+ ): Effect.Effect<A, never, R> =>
16
+ Effect.flatMap(read, (value) =>
17
+ pred(value) || rounds <= 0
18
+ ? Effect.succeed(value)
19
+ : Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
20
+ )
21
+
22
+ const RosterSchema = S.Struct({
23
+ members: S.Array(S.Struct({ name: S.String, tags: S.Array(S.String) })),
24
+ })
25
+
26
+ class RosterForm extends Form.make('HuntRosterForm', { schema: RosterSchema }) {}
27
+
28
+ const rosterLayer = Form.live(RosterForm, {
29
+ initial: {
30
+ members: [
31
+ { name: 'ada', tags: ['ada-1', 'ada-2'] },
32
+ { name: 'bob', tags: ['bob-1'] },
33
+ ],
34
+ },
35
+ validate: ({ values }) =>
36
+ values.members.flatMap((member, index) =>
37
+ member.name === 'ada' ? [Form.error(`members[${index}].name`, 'ada is blocked')] : [],
38
+ ),
39
+ }).pipe(Layer.provideMerge(Engine))
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // forms: per-item state is addressed by index and never re-indexed when the
43
+ // array is spliced or reordered, so it lands on a different item.
44
+ // ---------------------------------------------------------------------------
45
+
46
+ it.scopedLive('touched does not survive its item being removed from an array', () =>
47
+ Effect.gen(function* () {
48
+ const view = yield* Form.view(RosterForm)
49
+ // Blur ADA's name only. Bob's name is never touched by anybody.
50
+ yield* Event.dispatch(RosterForm.events.blur, { path: 'members[0].name' })
51
+ yield* waitUntil(RosterForm.state, (state) => state.touched['members[0].name'] === true)
52
+
53
+ view.array('members').remove(0)
54
+ const after = yield* waitUntil(RosterForm.state, (state) => state.values.members.length === 1)
55
+
56
+ expect(after.values.members[0]?.name).toBe('bob')
57
+ // The only touched field belonged to the removed item, so nothing is touched.
58
+ // Today `members[0].name` is still flagged, and index 0 is now Bob — a row the
59
+ // user never visited renders as touched (and therefore as "show my errors").
60
+ expect(after.touched).toEqual({})
61
+ }).pipe(Effect.provide(rosterLayer)),
62
+ )
63
+
64
+ it.scopedLive('a field error follows its item across a reorder', () =>
65
+ Effect.gen(function* () {
66
+ const view = yield* Form.view(RosterForm)
67
+ view.validate()
68
+ const invalid = yield* waitUntil(RosterForm.state, (state) => state.validationCount > 0)
69
+ expect(invalid.errors).toEqual({ 'members[0].name': 'ada is blocked' })
70
+
71
+ // Move Ada (index 0) to the end; Bob becomes index 0.
72
+ view.array('members').move(0, 1)
73
+ const after = yield* waitUntil(
74
+ RosterForm.state,
75
+ (state) => state.values.members[0]?.name === 'bob',
76
+ )
77
+
78
+ // Whatever the fix is — re-index the error onto Ada's new path, or drop stale
79
+ // errors on a structural change — Bob, who is now index 0, must not be wearing
80
+ // Ada's error message.
81
+ expect(after.errors['members[0].name']).toBe(undefined)
82
+ }).pipe(Effect.provide(rosterLayer)),
83
+ )
84
+
85
+ it.scopedLive('stable array keys of a nested list follow their parent item', () =>
86
+ Effect.gen(function* () {
87
+ const view = yield* Form.view(RosterForm)
88
+ const before = yield* RosterForm.state
89
+ const adaKey = before.arrayKeys['members']?.[0]
90
+ const bobKey = before.arrayKeys['members']?.[1]
91
+ const adaTagKeys = before.arrayKeys['members[0].tags']
92
+ const bobTagKeys = before.arrayKeys['members[1].tags']
93
+ expect(adaTagKeys?.length).toBe(2)
94
+ expect(bobTagKeys?.length).toBe(1)
95
+
96
+ view.array('members').move(0, 1)
97
+ const after = yield* waitUntil(
98
+ RosterForm.state,
99
+ (state) => state.values.members[0]?.name === 'bob',
100
+ )
101
+
102
+ // The item's OWN key is re-keyed correctly by moveAtPath ...
103
+ expect(after.arrayKeys['members']).toEqual([bobKey, adaKey])
104
+ // ... but the keys of the list nested INSIDE each item are not touched, so the
105
+ // key list at `members[0].tags` still describes Ada while index 0 holds Bob.
106
+ // Independent of which fix is chosen (carry the keys along, or regenerate them),
107
+ // a stable-key list must hand out exactly one key per item.
108
+ expect(after.values.members[0]?.tags.length).toBe(1)
109
+ expect(after.arrayKeys['members[0].tags']?.length).toBe(1)
110
+ expect(after.arrayKeys['members[1].tags']?.length).toBe(2)
111
+ }).pipe(Effect.provide(rosterLayer)),
112
+ )
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // proof: the harness compares values by a structural fingerprint string.
116
+ // ---------------------------------------------------------------------------
117
+
118
+ it('Proof.expect().toEqual() accepts a value that repeats a reference', () => {
119
+ const shared = { currency: 'USD' }
120
+ // `{ a: shared, b: shared }` is not cyclic — it is a DAG, and it is structurally
121
+ // equal to the literal on the right. The fingerprint's `seen` set is never
122
+ // unwound, so the second occurrence collapses to `[Circular]` and the assertion
123
+ // dies on two values that are equal.
124
+ const outcome = Effect.runSyncExit(
125
+ proofExpect({ a: shared, b: shared }).toEqual({
126
+ a: { currency: 'USD' },
127
+ b: { currency: 'USD' },
128
+ }),
129
+ )
130
+ expect(Exit.isSuccess(outcome)).toBe(true)
131
+ })
132
+
133
+ it('Proof.expect().toEqual() rejects a cyclic value against an acyclic one', () => {
134
+ const leaf = { currency: 'USD' }
135
+ const acyclic: Record<string, unknown> = { a: leaf, b: leaf }
136
+ const cyclic: Record<string, unknown> = { a: { currency: 'USD' } }
137
+ cyclic['b'] = cyclic
138
+
139
+ // Both fingerprint to `{a:{...},b:[Circular]}`, so the harness reports these two
140
+ // very different values as equal: an assertion that passes when it should fail.
141
+ const outcome = Effect.runSyncExit(proofExpect(acyclic).toEqual(cyclic))
142
+ expect(Exit.isFailure(outcome)).toBe(true)
143
+ })
144
+
145
+ it('Proof.expect().toEqual() ignores key insertion order', () => {
146
+ // Props built in a different key order than the literal in the proof body are
147
+ // structurally identical, but the fingerprint serialises `Reflect.ownKeys` in
148
+ // insertion order, so the assertion dies.
149
+ const outcome = Effect.runSyncExit(
150
+ proofExpect({ total: 3, label: 'x' }).toEqual({ label: 'x', total: 3 }),
151
+ )
152
+ expect(Exit.isSuccess(outcome)).toBe(true)
153
+ })
154
+
155
+ // `moveAt` accepts `to === elements.length` — "move it to the end" — but a move keeps
156
+ // the array's length, so the item actually lands at `length - 1`. The index map has to
157
+ // agree with where the value went, or the row's `touched` flag and validation error are
158
+ // parked on an index that does not exist and the next `append` inherits them.
159
+ interface Member {
160
+ readonly name: string
161
+ readonly tags: ReadonlyArray<string>
162
+ }
163
+
164
+ it('moving an item to the end carries its metadata to the index it landed on', () => {
165
+ const values = {
166
+ members: [
167
+ { name: 'a', tags: [] },
168
+ { name: 'b', tags: [] },
169
+ { name: 'c', tags: [] },
170
+ ],
171
+ } satisfies { readonly members: ReadonlyArray<Member> }
172
+ const state: FormStateShape<{ readonly members: ReadonlyArray<Member> }> = {
173
+ values,
174
+ initialValues: values,
175
+ touched: { 'members[0].name': true },
176
+ errors: { 'members[0].name': 'first is wrong' },
177
+ arrayKeys: {},
178
+ dirtyPaths: [],
179
+ submitCount: 0,
180
+ validationCount: 0,
181
+ lastSubmittedValues: Option.none(),
182
+ }
183
+ const names = (moved: FormStateShape<{ readonly members: ReadonlyArray<Member> }>) =>
184
+ moved.values.members.map((member: Member) => member.name)
185
+
186
+ // Control: the in-range form of the same move places the metadata correctly.
187
+ const inRange = FormState.moveAtPath({ state, path: 'members', from: 0, to: 2 })
188
+ expect(names(inRange)).toEqual(['b', 'c', 'a'])
189
+ expect(inRange.touched['members[2].name']).toBe(true)
190
+ expect(inRange.errors['members[2].name']).toBe('first is wrong')
191
+
192
+ // Subject: `to === length` is the same move, so the metadata must land in the same place.
193
+ const toEnd = FormState.moveAtPath({ state, path: 'members', from: 0, to: 3 })
194
+ expect(names(toEnd)).toEqual(['b', 'c', 'a'])
195
+ expect(toEnd.touched['members[2].name']).toBe(true)
196
+ expect(toEnd.errors['members[2].name']).toBe('first is wrong')
197
+ expect(toEnd.touched['members[3].name']).toBeUndefined()
198
+ })
package/src/formState.ts CHANGED
@@ -1,12 +1,7 @@
1
1
  import { Function as Fn, Option, Record } from 'effect'
2
- import {
3
- getNestedValue,
4
- moveAt,
5
- recalculateDirtyPaths,
6
- replaceAt,
7
- setNestedValue,
8
- } from './path'
2
+ import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue } from './path'
9
3
  import type { FormState } from './formTypes'
4
+ import { movedIndexMap, reindexMetadata, removedIndexMap, swappedIndexMap } from './formReindex'
10
5
 
11
6
  const arrayKeyCounter = { current: 0 }
12
7
  const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
@@ -103,18 +98,39 @@ const updateKeys = (
103
98
  return { ...state.arrayKeys, [path]: transform(existing) }
104
99
  }
105
100
 
106
- export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
107
- withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
101
+ // The subtree at `path` is being replaced, so every key describing a row inside it
102
+ // is about to describe nothing. Left behind, the next append folds over the stale
103
+ // array and hands a new row the identity of one the user discarded.
104
+ const withoutSubtree = (
105
+ keys: Readonly<Record<string, ReadonlyArray<string>>>,
106
+ path: string,
107
+ ): Record<string, ReadonlyArray<string>> =>
108
+ Record.fromEntries(
109
+ Record.toEntries(keys).filter(
110
+ ([key]) => key !== path && !key.startsWith(`${path}[`) && !key.startsWith(`${path}.`),
111
+ ),
112
+ )
113
+
114
+ export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => ({
115
+ ...withValues(input.state, setNestedValue(input.state.values, input.path, input.value)),
116
+ arrayKeys: {
117
+ ...withoutSubtree(input.state.arrayKeys, input.path),
118
+ ...arrayKeysFor(input.value, input.path),
119
+ },
120
+ })
108
121
 
109
122
  export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
110
123
  const elements = currentArray({ source: input.state.values, path: input.path })
111
124
  const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
112
125
  return {
113
126
  ...withValues(input.state, nextValues),
114
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
115
- ...keys,
116
- makeArrayKey(),
117
- ]),
127
+ arrayKeys: {
128
+ ...updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
129
+ // Arrays nested inside the appended item need keys of their own — `initialState`
130
+ // seeds every array in the tree, and a nested list without them falls back to a
131
+ // positional key, so its rows change identity the first time one is touched.
132
+ ...arrayKeysFor(input.value, `${input.path}[${elements.length}]`),
133
+ },
118
134
  }
119
135
  }
120
136
 
@@ -128,10 +144,19 @@ export const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<
128
144
  input.path,
129
145
  elements.filter((_, position) => position !== input.index),
130
146
  )
147
+ const moved = reindexMetadata({
148
+ state: input.state,
149
+ path: input.path,
150
+ moveIndex: removedIndexMap(input.index),
151
+ })
131
152
  return {
132
153
  ...withValues(input.state, nextValues),
133
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
134
- keys.filter((_, position) => position !== input.index),
154
+ touched: moved.touched,
155
+ errors: moved.errors,
156
+ arrayKeys: updateKeys(
157
+ Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
158
+ input.path,
159
+ (keys) => keys.filter((_, position) => position !== input.index),
135
160
  ),
136
161
  }
137
162
  }
@@ -143,10 +168,25 @@ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Valu
143
168
  return input.state
144
169
  }
145
170
  const nextValues = setNestedValue(input.state.values, input.path, next)
171
+ const moved = reindexMetadata({
172
+ state: input.state,
173
+ path: input.path,
174
+ // `moveAt` accepts `to === elements.length` as "move it to the end", but a move
175
+ // keeps the length, so the item lands one slot short of that. The metadata has to
176
+ // follow the value, not the argument.
177
+ moveIndex: movedIndexMap({
178
+ from: input.from,
179
+ to: Math.min(input.to, elements.length - 1),
180
+ }),
181
+ })
146
182
  return {
147
183
  ...withValues(input.state, nextValues),
148
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
149
- moveAt(keys, input.from, input.to),
184
+ touched: moved.touched,
185
+ errors: moved.errors,
186
+ arrayKeys: updateKeys(
187
+ Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
188
+ input.path,
189
+ (keys) => moveAt(keys, input.from, input.to),
150
190
  ),
151
191
  }
152
192
  }
@@ -168,14 +208,24 @@ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Valu
168
208
  elements[input.first],
169
209
  )
170
210
  const nextValues = setNestedValue(input.state.values, input.path, swapped)
211
+ const moved = reindexMetadata({
212
+ state: input.state,
213
+ path: input.path,
214
+ moveIndex: swappedIndexMap({ first: input.first, second: input.second }),
215
+ })
171
216
  return {
172
217
  ...withValues(input.state, nextValues),
173
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
174
- replaceAt(
175
- replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
176
- input.second,
177
- keys[input.first] ?? makeArrayKey(),
178
- ),
218
+ touched: moved.touched,
219
+ errors: moved.errors,
220
+ arrayKeys: updateKeys(
221
+ Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
222
+ input.path,
223
+ (keys) =>
224
+ replaceAt(
225
+ replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
226
+ input.second,
227
+ keys[input.first] ?? makeArrayKey(),
228
+ ),
179
229
  ),
180
230
  }
181
231
  }