@playfast/reform-forms 1.2.1 → 1.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playfast/reform-forms",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
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",
@@ -21,15 +21,26 @@
21
21
  "directory": "packages/forms"
22
22
  },
23
23
  "files": [
24
+ "dist",
24
25
  "src",
25
26
  "README.md"
26
27
  ],
27
28
  "type": "module",
28
29
  "sideEffects": false,
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
29
32
  "exports": {
30
33
  "./package.json": "./package.json",
31
- ".": "./src/index.ts",
32
- "./*": "./src/*.ts"
34
+ ".": {
35
+ "playfast-src": "./src/index.ts",
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./*": {
40
+ "playfast-src": "./src/*.ts",
41
+ "types": "./dist/*.d.ts",
42
+ "default": "./dist/*.js"
43
+ }
33
44
  },
34
45
  "publishConfig": {
35
46
  "access": "public"
@@ -37,12 +48,13 @@
37
48
  "scripts": {
38
49
  "clean": "rm -rf dist .tsbuildinfo",
39
50
  "check": "tsc --noEmit",
40
- "build": "tsc -p tsconfig.build.json",
51
+ "build": "rm -rf dist .tsbuildinfo && tsc -p tsconfig.build.json && bun ../../scripts/fix-esm-extensions.ts dist",
41
52
  "test": "vitest run",
42
53
  "test:watch": "vitest",
43
54
  "coverage": "vitest run --coverage",
44
55
  "lint": "oxlint src",
45
- "lint:fix": "oxlint --fix src"
56
+ "lint:fix": "oxlint --fix src",
57
+ "prepack": "bun run build"
46
58
  },
47
59
  "peerDependencies": {
48
60
  "@playfast/reform": "*",
@@ -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
+ })