@byline/admin 4.4.0 → 4.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.
Files changed (33) hide show
  1. package/dist/fields/array/array-field.js +20 -24
  2. package/dist/fields/blocks/blocks-field.js +19 -17
  3. package/dist/fields/file/file-upload-field.js +2 -2
  4. package/dist/fields/image/image-upload-field.js +12 -3
  5. package/dist/forms/__probe-nested.test.node.d.ts +1 -0
  6. package/dist/forms/__probe-two.test.node.d.ts +1 -0
  7. package/dist/forms/form-context.d.ts +2 -1
  8. package/dist/forms/form-context.js +17 -3
  9. package/dist/forms/form-renderer.js +2 -0
  10. package/dist/forms/nested-path.d.ts +5 -1
  11. package/dist/forms/nested-path.js +84 -15
  12. package/dist/forms/pending-uploads.d.ts +6 -0
  13. package/dist/forms/pending-uploads.js +11 -0
  14. package/dist/forms/pending-uploads.test.node.d.ts +1 -0
  15. package/dist/forms/repeating-items.d.ts +16 -0
  16. package/dist/forms/repeating-items.js +28 -0
  17. package/dist/forms/repeating-items.test.node.d.ts +1 -0
  18. package/package.json +5 -5
  19. package/src/fields/array/array-field.tsx +28 -38
  20. package/src/fields/blocks/blocks-field.tsx +24 -27
  21. package/src/fields/code/code-field.tsx +1 -1
  22. package/src/fields/file/file-upload-field.tsx +9 -5
  23. package/src/fields/image/image-upload-field.tsx +24 -6
  24. package/src/forms/form-context.tsx +31 -4
  25. package/src/forms/form-renderer.tsx +2 -0
  26. package/src/forms/nested-path.test.node.ts +50 -1
  27. package/src/forms/nested-path.ts +106 -18
  28. package/src/forms/pending-uploads.test.node.ts +23 -0
  29. package/src/forms/pending-uploads.ts +22 -0
  30. package/src/forms/repeating-items.test.node.ts +36 -0
  31. package/src/forms/repeating-items.ts +48 -0
  32. package/src/forms/upload-executor.test.node.ts +64 -3
  33. package/src/forms/upload-executor.ts +4 -1
@@ -18,6 +18,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
18
18
  import { FieldRenderer } from '../../fields/field-renderer'
19
19
  import { SortableItem, StaticItem } from '../../fields/sortable-item'
20
20
  import { useFormContext } from '../../forms/form-context'
21
+ import { hasExistingIdTargets } from '../../forms/nested-path'
22
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
21
23
  import styles from './array-field.module.css'
22
24
 
23
25
  // ---------------------------------------------------------------------------
@@ -55,7 +57,8 @@ export const ArrayField = ({
55
57
  */
56
58
  fieldAdmin?: Record<string, FieldAdminConfig>
57
59
  }) => {
58
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
60
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
61
+ useFormContext()
59
62
  const { t } = useTranslation('byline-admin')
60
63
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
61
64
 
@@ -72,10 +75,10 @@ export const ArrayField = ({
72
75
  setItems(
73
76
  source.map((item: any) => ({
74
77
  id:
75
- item && typeof item === 'object' && 'id' in item
76
- ? String((item as { id: string }).id)
77
- : item && typeof item === 'object' && '_id' in item
78
- ? String((item as { _id: string })._id)
78
+ item && typeof item === 'object' && '_id' in item
79
+ ? String((item as { _id: string })._id)
80
+ : item && typeof item === 'object' && 'id' in item
81
+ ? String((item as { id: string }).id)
79
82
  : crypto.randomUUID(),
80
83
  data: item,
81
84
  }))
@@ -85,23 +88,6 @@ export const ArrayField = ({
85
88
  }
86
89
  }, [defaultValue, getFieldValue, path])
87
90
 
88
- /**
89
- * Stable patch identity for an array item. Persisted items carry `_id`
90
- * (the array-item identity from `store_meta`) — that is what the server's
91
- * patch engine matches on (`applyArrayPatch`: `item._id === patch.itemId`),
92
- * so it MUST be preferred here. `id` is accepted as a legacy/seed-data
93
- * alias. Items added this session have neither (the storage layer assigns
94
- * `_id` at write time), so fall back to the item's current index — the
95
- * patch engine resolves a pure-integer itemId as an index fallback.
96
- */
97
- const patchItemId = (item: unknown, index: number): string => {
98
- if (item && typeof item === 'object') {
99
- if ('_id' in item) return String((item as { _id: string })._id)
100
- if ('id' in item) return String((item as { id: string }).id)
101
- }
102
- return String(index)
103
- }
104
-
105
91
  const handleDragEnd = ({
106
92
  moveFromIndex,
107
93
  moveToIndex,
@@ -109,23 +95,20 @@ export const ArrayField = ({
109
95
  moveFromIndex: number
110
96
  moveToIndex: number
111
97
  }) => {
112
- setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
113
98
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
99
+ if (!Array.isArray(currentArray)) return
114
100
 
115
- if (Array.isArray(currentArray)) {
116
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1))
117
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1))
118
- if (clampedFrom === clampedTo) return
101
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
102
+ if (move == null) return
119
103
 
120
- const item = currentArray[clampedFrom]
121
-
122
- appendPatch({
123
- kind: 'array.move',
124
- path: path,
125
- itemId: patchItemId(item, clampedFrom),
126
- toIndex: clampedTo,
127
- })
128
- }
104
+ setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
105
+ setFieldStore(path, move.items)
106
+ appendPatch({
107
+ kind: 'array.move',
108
+ path,
109
+ itemId: move.itemId,
110
+ toIndex: move.toIndex,
111
+ })
129
112
  }
130
113
 
131
114
  const handleAddItem = async (atIndex?: number) => {
@@ -153,6 +136,11 @@ export const ArrayField = ({
153
136
  }
154
137
  }
155
138
 
139
+ // Defaults may resolve asynchronously. If an enclosing stable-id item was
140
+ // removed in the meantime, do not append a structural patch that would
141
+ // recreate that missing parent on the server.
142
+ if (!hasExistingIdTargets(getFieldValues(), path)) return
143
+
156
144
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
157
145
  const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
158
146
 
@@ -180,13 +168,15 @@ export const ArrayField = ({
180
168
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
181
169
 
182
170
  const item = currentArray[index]
171
+ const itemPath = repeatingItemPath(path, item, index)
183
172
 
184
173
  setItems((prev) => prev.filter((_, i) => i !== index))
174
+ removePendingUploadsUnder(itemPath)
185
175
 
186
176
  appendPatch({
187
177
  kind: 'array.remove',
188
178
  path: path,
189
- itemId: patchItemId(item, index),
179
+ itemId: repeatingItemId(item) ?? String(index),
190
180
  })
191
181
 
192
182
  const newArrayValue = [...currentArray]
@@ -200,7 +190,7 @@ export const ArrayField = ({
200
190
 
201
191
  const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
202
192
  const item = itemWrapper.data
203
- const arrayElementPath = `${path}[${index}]`
193
+ const arrayElementPath = repeatingItemPath(path, item, index)
204
194
 
205
195
  if (!item || typeof item !== 'object') return null
206
196
 
@@ -31,6 +31,8 @@ import { defaultScalarForField } from '../../fields/field-helpers'
31
31
  import { GroupField } from '../../fields/group/group-field'
32
32
  import { SortableItem } from '../../fields/sortable-item'
33
33
  import { useFormContext } from '../../forms/form-context'
34
+ import { hasExistingIdTargets } from '../../forms/nested-path'
35
+ import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
34
36
  import styles from './blocks-field.module.css'
35
37
 
36
38
  // ---------------------------------------------------------------------------
@@ -54,7 +56,8 @@ export const BlocksField = ({
54
56
  */
55
57
  contentLocale?: string
56
58
  }) => {
57
- const { appendPatch, getFieldValue, getFieldValues, setFieldStore } = useFormContext()
59
+ const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
60
+ useFormContext()
58
61
  const { t } = useTranslation('byline-admin')
59
62
  const [items, setItems] = useState<{ id: string; data: any }[]>([])
60
63
  const [showAddBlockModal, setShowAddBlockModal] = useState(false)
@@ -118,27 +121,20 @@ export const BlocksField = ({
118
121
  moveFromIndex: number
119
122
  moveToIndex: number
120
123
  }) => {
121
- setItems((prev) => moveItem(prev, moveFromIndex, moveToIndex))
122
124
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
125
+ if (!Array.isArray(currentArray)) return
123
126
 
124
- if (Array.isArray(currentArray)) {
125
- const clampedFrom = Math.max(0, Math.min(moveFromIndex, currentArray.length - 1))
126
- const clampedTo = Math.max(0, Math.min(moveToIndex, currentArray.length - 1))
127
- if (clampedFrom === clampedTo) return
128
-
129
- const item = currentArray[clampedFrom]
130
- const itemId =
131
- item && typeof item === 'object' && '_id' in item
132
- ? String((item as { _id: string })._id)
133
- : String(clampedFrom)
134
-
135
- appendPatch({
136
- kind: 'array.move',
137
- path: path,
138
- itemId,
139
- toIndex: clampedTo,
140
- })
141
- }
127
+ const move = moveRepeatingItems(currentArray, moveFromIndex, moveToIndex)
128
+ if (move == null) return
129
+
130
+ setItems((prev) => moveItem(prev, move.fromIndex, move.toIndex))
131
+ setFieldStore(path, move.items)
132
+ appendPatch({
133
+ kind: 'array.move',
134
+ path,
135
+ itemId: move.itemId,
136
+ toIndex: move.toIndex,
137
+ })
142
138
  }
143
139
 
144
140
  const handleAddItem = async (forcedVariantName?: string, atIndex?: number) => {
@@ -163,6 +159,8 @@ export const BlocksField = ({
163
159
  newItem[f.name] = await defaultScalarForField(f, getFieldValues)
164
160
  }
165
161
 
162
+ if (!hasExistingIdTargets(getFieldValues(), path)) return
163
+
166
164
  const currentArray = (getFieldValue(path) ?? defaultValue) as any[]
167
165
  const insertAt = atIndex != null ? atIndex : currentArray ? currentArray.length : 0
168
166
 
@@ -190,12 +188,11 @@ export const BlocksField = ({
190
188
  if (!Array.isArray(currentArray) || index < 0 || index >= currentArray.length) return
191
189
 
192
190
  const item = currentArray[index]
193
- const itemId =
194
- item && typeof item === 'object' && '_id' in item
195
- ? String((item as { _id: string })._id)
196
- : String(index)
191
+ const itemPath = repeatingItemPath(path, item, index)
192
+ const itemId = repeatingItemId(item) ?? String(index)
197
193
 
198
194
  setItems((prev) => prev.filter((_, i) => i !== index))
195
+ removePendingUploadsUnder(itemPath)
199
196
 
200
197
  appendPatch({
201
198
  kind: 'array.remove',
@@ -219,7 +216,7 @@ export const BlocksField = ({
219
216
 
220
217
  const renderItem = (itemWrapper: { id: string; data: any }, index: number) => {
221
218
  const item = itemWrapper.data
222
- const arrayElementPath = `${path}[${index}]`
219
+ const arrayElementPath = repeatingItemPath(path, item, index)
223
220
 
224
221
  if (!item || typeof item !== 'object' || typeof item._type !== 'string') return null
225
222
 
@@ -233,9 +230,9 @@ export const BlocksField = ({
233
230
  // Render the block's children directly with arrayElementPath as the
234
231
  // path (not basePath). FieldRenderer would append the group name
235
232
  // (e.g. "richTextBlock") producing paths like
236
- // "content[0].richTextBlock.constrainedWidth", but the flat block
233
+ // "content[id=...].richTextBlock.constrainedWidth", but the flat block
237
234
  // shape stores fields directly on the item so the correct path is
238
- // "content[0].constrainedWidth".
235
+ // "content[id=...].constrainedWidth".
239
236
  const body = (
240
237
  <GroupField
241
238
  key={subField.blockType}
@@ -24,7 +24,7 @@ const CodeEditor = React.lazy(() => import('./code-editor'))
24
24
 
25
25
  /**
26
26
  * Resolve the form-store path of a sibling field (same group/block/array
27
- * item scope). `content[0].code` + `language` → `content[0].language`;
27
+ * item scope). `content[id=x].code` + `language` → `content[id=x].language`;
28
28
  * a top-level `code` + `language` → `language`.
29
29
  */
30
30
  const siblingFieldPath = (fieldPath: string, siblingName: string): string => {
@@ -71,11 +71,15 @@ export const FileUploadField = ({
71
71
 
72
72
  const pendingValue = createPendingStoredFileValue(file, previewUrl)
73
73
 
74
- addPendingUpload(fieldPath, {
75
- file,
76
- previewUrl,
77
- collectionPath,
78
- })
74
+ if (
75
+ !addPendingUpload(fieldPath, {
76
+ file,
77
+ previewUrl,
78
+ collectionPath,
79
+ })
80
+ ) {
81
+ return
82
+ }
79
83
 
80
84
  setStatus('idle')
81
85
  onUploaded(pendingValue)
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { ChangeEvent, DragEvent } from 'react'
21
- import { useCallback, useRef, useState } from 'react'
21
+ import { useCallback, useEffect, useRef, useState } from 'react'
22
22
 
23
23
  import {
24
24
  createPendingStoredFileValue,
@@ -62,12 +62,20 @@ export const ImageUploadField = ({
62
62
  accept = 'image/*',
63
63
  }: ImageUploadFieldProps) => {
64
64
  const inputRef = useRef<HTMLInputElement>(null)
65
+ const mountedRef = useRef(true)
65
66
  const [status, setStatus] = useState<SelectionStatus>('idle')
66
67
  const [errorMessage, setErrorMessage] = useState<string | null>(null)
67
68
  const [isDragOver, setIsDragOver] = useState(false)
68
69
  const { addPendingUpload } = useFormContext()
69
70
  const { t } = useTranslation('byline-admin')
70
71
 
72
+ useEffect(() => {
73
+ mountedRef.current = true
74
+ return () => {
75
+ mountedRef.current = false
76
+ }
77
+ }, [])
78
+
71
79
  // -------------------------------------------------------------------------
72
80
  // Core file selection logic (deferred upload)
73
81
  // -------------------------------------------------------------------------
@@ -90,6 +98,11 @@ export const ImageUploadField = ({
90
98
  // Extract image dimensions for the pending value
91
99
  const img = new Image()
92
100
  img.onload = () => {
101
+ if (!mountedRef.current) {
102
+ URL.revokeObjectURL(previewUrl)
103
+ return
104
+ }
105
+
93
106
  // SVGs without explicit width/height attrs (viewBox-only) report naturalWidth/Height = 0.
94
107
  // Skip dimensions when zero so they are stored as null (scalable, no fixed size).
95
108
  const w = img.naturalWidth
@@ -100,11 +113,15 @@ export const ImageUploadField = ({
100
113
  const pendingValue = createPendingStoredFileValue(file, previewUrl, dimensions)
101
114
 
102
115
  // Register the pending upload in form context
103
- addPendingUpload(fieldPath, {
104
- file,
105
- previewUrl,
106
- collectionPath,
107
- })
116
+ if (
117
+ !addPendingUpload(fieldPath, {
118
+ file,
119
+ previewUrl,
120
+ collectionPath,
121
+ })
122
+ ) {
123
+ return
124
+ }
108
125
 
109
126
  setStatus('idle')
110
127
  onUploaded(pendingValue)
@@ -112,6 +129,7 @@ export const ImageUploadField = ({
112
129
 
113
130
  img.onerror = () => {
114
131
  URL.revokeObjectURL(previewUrl)
132
+ if (!mountedRef.current) return
115
133
  setStatus('error')
116
134
  setErrorMessage(t('fields.image.upload.errors.cannotRead'))
117
135
  }
@@ -19,7 +19,12 @@ import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
19
19
  // outright. A bare `from 'lodash-es'` import otherwise pools into a single
20
20
  // ~85KB chunk that leaks onto the public frontend bundle (form-context is
21
21
  // reachable from the layout graph).
22
- import { get as getNestedValue, set as setNestedValue } from './nested-path'
22
+ import {
23
+ get as getNestedValue,
24
+ hasExistingIdTargets,
25
+ setWithResult as setNestedValue,
26
+ } from './nested-path'
27
+ import { deletePendingUploadsUnderPath } from './pending-uploads'
23
28
  import { useTrackedSlot } from './use-tracked-slot'
24
29
 
25
30
  interface FormError {
@@ -130,8 +135,9 @@ interface FormContextType {
130
135
  subscribeErrors: (listener: ErrorsListener) => () => void
131
136
  subscribeMeta: (listener: MetaListener) => () => void
132
137
  // Pending uploads (deferred until save)
133
- addPendingUpload: (fieldPath: string, upload: PendingUpload) => void
138
+ addPendingUpload: (fieldPath: string, upload: PendingUpload) => boolean
134
139
  removePendingUpload: (fieldPath: string) => void
140
+ removePendingUploadsUnder: (itemPath: string) => void
135
141
  getPendingUploads: () => Map<string, PendingUpload>
136
142
  hasPendingUploads: () => boolean
137
143
  clearPendingUploads: () => void
@@ -284,13 +290,14 @@ export const FormProvider = ({
284
290
  const newFieldValues = { ...fieldValues.current }
285
291
 
286
292
  // Keep nested path values up to date for generic usage and patches.
287
- setNestedValue(newFieldValues, name, value)
293
+ if (!setNestedValue(newFieldValues, name, value)) return false
288
294
 
289
295
  fieldValues.current = newFieldValues
290
296
  dirtyFields.current.add(name)
291
297
 
292
298
  notifyFieldListeners(name, value)
293
299
  notifyMetaListeners()
300
+ return true
294
301
  },
295
302
  [notifyFieldListeners, notifyMetaListeners]
296
303
  )
@@ -304,7 +311,7 @@ export const FormProvider = ({
304
311
 
305
312
  const setFieldValue = useCallback(
306
313
  (name: string, value: any) => {
307
- updateFieldStoreInternal(name, value)
314
+ if (!updateFieldStoreInternal(name, value)) return
308
315
 
309
316
  const patch: FieldSetPatch = {
310
317
  kind: 'field.set',
@@ -421,6 +428,14 @@ export const FormProvider = ({
421
428
 
422
429
  const addPendingUpload = useCallback(
423
430
  (fieldPath: string, upload: PendingUpload) => {
431
+ // Image metadata extraction is asynchronous. If its containing item was
432
+ // removed while decoding, discard the late registration rather than
433
+ // allowing submit to recreate or overwrite an item through a stale path.
434
+ if (!hasExistingIdTargets(fieldValues.current, fieldPath)) {
435
+ URL.revokeObjectURL(upload.previewUrl)
436
+ return false
437
+ }
438
+
424
439
  // If there's an existing pending upload for this path, revoke its blob URL
425
440
  const existing = pendingUploadsRef.current.get(fieldPath)
426
441
  if (existing) {
@@ -429,6 +444,7 @@ export const FormProvider = ({
429
444
  pendingUploadsRef.current.set(fieldPath, upload)
430
445
  dirtyFields.current.add(fieldPath)
431
446
  notifyMetaListeners()
447
+ return true
432
448
  },
433
449
  [notifyMetaListeners]
434
450
  )
@@ -445,6 +461,16 @@ export const FormProvider = ({
445
461
  [notifyMetaListeners]
446
462
  )
447
463
 
464
+ const removePendingUploadsUnder = useCallback(
465
+ (itemPath: string) => {
466
+ const deleted = deletePendingUploadsUnderPath(pendingUploadsRef.current, itemPath, (url) =>
467
+ URL.revokeObjectURL(url)
468
+ )
469
+ if (deleted) notifyMetaListeners()
470
+ },
471
+ [notifyMetaListeners]
472
+ )
473
+
448
474
  const getPendingUploads = useCallback(() => {
449
475
  return new Map(pendingUploadsRef.current)
450
476
  }, [])
@@ -708,6 +734,7 @@ export const FormProvider = ({
708
734
  subscribeMeta,
709
735
  addPendingUpload,
710
736
  removePendingUpload,
737
+ removePendingUploadsUnder,
711
738
  getPendingUploads,
712
739
  hasPendingUploads,
713
740
  clearPendingUploads,
@@ -551,6 +551,8 @@ const FormContent = ({
551
551
  noValidate
552
552
  onSubmit={handleSubmit}
553
553
  className={cx('byline-form', styles.form)}
554
+ inert={isUploading ? true : undefined}
555
+ aria-busy={isUploading}
554
556
  >
555
557
  <div className={cx('byline-form-heading-row', styles['heading-row'])}>
556
558
  <h1 className={cx('byline-form-heading', styles.heading)}>{heading}</h1>
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, it } from 'vitest'
10
10
 
11
- import { get, set, toPath } from './nested-path'
11
+ import { get, hasExistingIdTargets, set, setWithResult, toPath } from './nested-path'
12
12
 
13
13
  describe('toPath', () => {
14
14
  it('parses dot and bracket notation', () => {
@@ -28,6 +28,22 @@ describe('get', () => {
28
28
  expect(get(obj, 'items[1].title')).toBe('y')
29
29
  })
30
30
 
31
+ it('selects nested repeating items by stable id', () => {
32
+ const value = {
33
+ content: [
34
+ {
35
+ _id: 'block-b',
36
+ gallery: [
37
+ { _id: 'image-1', alt: 'one' },
38
+ { _id: 'image-2', alt: 'two' },
39
+ ],
40
+ },
41
+ ],
42
+ }
43
+ expect(get(value, 'content[id=block-b].gallery[id=image-2].alt')).toBe('two')
44
+ expect(get(value, 'content[id=missing].gallery[id=image-2].alt')).toBeUndefined()
45
+ })
46
+
31
47
  it('preserves falsy values (does not conflate with missing)', () => {
32
48
  expect(get(obj, 'a.zero')).toBe(0)
33
49
  expect(get(obj, 'a.empty')).toBe('')
@@ -78,8 +94,41 @@ describe('set', () => {
78
94
  expect(Array.isArray(o.items)).toBe(true)
79
95
  })
80
96
 
97
+ it('writes through nested stable-id selectors without changing array order', () => {
98
+ const o = {
99
+ content: [
100
+ { _id: 'a', gallery: [{ _id: 'x', alt: 'keep' }] },
101
+ { _id: 'b', gallery: [{ _id: 'y', alt: 'old' }] },
102
+ ],
103
+ }
104
+ set(o, 'content[id=b].gallery[id=y].alt', 'new')
105
+ expect(o.content.map((item) => item._id)).toEqual(['a', 'b'])
106
+ expect(o.content[0]?.gallery[0]?.alt).toBe('keep')
107
+ expect(o.content[1]?.gallery[0]?.alt).toBe('new')
108
+ })
109
+
110
+ it('does not create a ghost item for an unknown stable id', () => {
111
+ const o = { items: [{ _id: 'a', title: 'keep' }] }
112
+ expect(setWithResult(o, 'items[id=gone].title', 'new')).toBe(false)
113
+ expect(o).toEqual({ items: [{ _id: 'a', title: 'keep' }] })
114
+
115
+ const missingContainer: { group: { items?: unknown[] } } = { group: {} }
116
+ expect(setWithResult(missingContainer, 'group.items[id=gone].title', 'new')).toBe(false)
117
+ expect(missingContainer).toEqual({ group: {} })
118
+ })
119
+
81
120
  it('returns the mutated root', () => {
82
121
  const o: any = {}
83
122
  expect(set(o, 'x', 1)).toBe(o)
84
123
  })
85
124
  })
125
+
126
+ describe('hasExistingIdTargets', () => {
127
+ const value = { items: [{ _id: 'a', nested: [{ _id: 'b' }] }] }
128
+
129
+ it('accepts live nested identities and rejects removed ones', () => {
130
+ expect(hasExistingIdTargets(value, 'items[id=a].nested[id=b].title')).toBe(true)
131
+ expect(hasExistingIdTargets(value, 'items[id=a].nested[id=gone].title')).toBe(false)
132
+ expect(hasExistingIdTargets(value, 'title')).toBe(true)
133
+ })
134
+ })
@@ -8,7 +8,7 @@
8
8
  * Minimal nested `get`/`set` over string field paths, replacing lodash-es
9
9
  * (which pulled a large shared chunk onto unrelated bundles). Supports the
10
10
  * dot + bracket notation produced by the form field-path builders, e.g.
11
- * `title`, `a.b.c`, `items[0].title`, `blocks[2].nested[1].field`.
11
+ * `title`, `a.b.c`, `items[0].title`, `blocks[id=abc].nested[1].field`.
12
12
  *
13
13
  * `set` mirrors lodash semantics: it creates intermediate **arrays** when the
14
14
  * next path segment is a numeric index and plain **objects** otherwise, and it
@@ -19,42 +19,130 @@
19
19
  * form paths ever produce. See nested-path.test.node.ts for the covered cases.
20
20
  */
21
21
 
22
- const isIndexKey = (key: string): boolean => /^(?:0|[1-9]\d*)$/.test(key)
22
+ import { type PathSegment, parseInstancePath } from '@byline/core'
23
23
 
24
24
  /** Split a field path into segments: `items[0].title` -> ['items','0','title']. */
25
25
  export function toPath(path: string): string[] {
26
26
  return path.match(/[^.[\]]+/g) ?? []
27
27
  }
28
28
 
29
+ function selectId(value: unknown, id: string): number {
30
+ if (!Array.isArray(value)) return -1
31
+ return value.findIndex((item) => item != null && typeof item === 'object' && item._id === id)
32
+ }
33
+
34
+ function newContainer(next: PathSegment | undefined): any[] | Record<string, unknown> {
35
+ return next?.kind === 'index' || next?.kind === 'id' ? [] : {}
36
+ }
37
+
29
38
  // Returns `any` (not `T | undefined`) to match lodash's loose `get` contract,
30
39
  // so existing call sites that treat the result as `any` keep type-checking.
31
40
  export function get<T = any>(object: unknown, path: string): T {
32
41
  if (object == null) return undefined as T
42
+ const parsed = parseInstancePath(path)
43
+ if (!parsed.ok) return undefined as T
44
+
33
45
  let current: any = object
34
- for (const key of toPath(path)) {
46
+ for (const segment of parsed.segments) {
35
47
  if (current == null) return undefined as T
36
- current = current[key]
48
+ if (segment.kind === 'field') {
49
+ current = current[segment.name]
50
+ } else if (segment.kind === 'index') {
51
+ current = current[segment.index]
52
+ } else if (segment.kind === 'id') {
53
+ const index = selectId(current, segment.id)
54
+ if (index === -1) return undefined as T
55
+ current = current[index]
56
+ } else {
57
+ return undefined as T
58
+ }
37
59
  }
38
60
  return current as T
39
61
  }
40
62
 
41
- export function set<T extends object>(object: T, path: string, value: unknown): T {
42
- if (object == null) return object
43
- const keys = toPath(path)
44
- if (keys.length === 0) return object
63
+ /** Whether every stable-id selector in a path still identifies a live item. */
64
+ export function hasExistingIdTargets(object: unknown, path: string): boolean {
65
+ const parsed = parseInstancePath(path)
66
+ if (!parsed.ok) return false
67
+
68
+ let current: any = object
69
+ for (const segment of parsed.segments) {
70
+ if (segment.kind === 'field') {
71
+ current = current?.[segment.name]
72
+ } else if (segment.kind === 'index') {
73
+ current = current?.[segment.index]
74
+ } else if (segment.kind === 'id') {
75
+ const index = selectId(current, segment.id)
76
+ if (index === -1) return false
77
+ current = current[index]
78
+ } else {
79
+ return false
80
+ }
81
+ }
82
+ return true
83
+ }
84
+
85
+ /** Set a path and report whether all stable-id selectors resolved. */
86
+ export function setWithResult<T extends object>(object: T, path: string, value: unknown): boolean {
87
+ if (object == null) return false
88
+ const parsed = parseInstancePath(path)
89
+ if (!parsed.ok || parsed.segments.length === 0) return false
45
90
 
46
91
  let current: any = object
47
- for (let i = 0; i < keys.length - 1; i++) {
48
- // Bounded by the loop condition, so these indexed reads are always defined.
49
- const key = keys[i] as string
50
- const nextKey = keys[i + 1] as string
51
- const existing = current[key]
52
- if (existing == null || typeof existing !== 'object') {
53
- // Create the container the next segment needs: array for an index, else object.
54
- current[key] = isIndexKey(nextKey) ? [] : {}
92
+ for (let i = 0; i < parsed.segments.length; i++) {
93
+ const segment = parsed.segments[i] as PathSegment
94
+ const next = parsed.segments[i + 1]
95
+ const last = i === parsed.segments.length - 1
96
+
97
+ if (segment.kind === 'field') {
98
+ if (last) {
99
+ current[segment.name] = value
100
+ return true
101
+ }
102
+ const existing = current[segment.name]
103
+ if (existing == null || typeof existing !== 'object') {
104
+ // Do not create a partial container on the way to an item identity
105
+ // that may no longer exist. Normal non-ID lodash-style writes still
106
+ // create their intermediate structure below.
107
+ if (parsed.segments.slice(i + 1).some((candidate) => candidate.kind === 'id')) return false
108
+ current[segment.name] = newContainer(next)
109
+ }
110
+ current = current[segment.name]
111
+ continue
55
112
  }
56
- current = current[key]
113
+
114
+ if (segment.kind === 'index') {
115
+ if (!Array.isArray(current)) return false
116
+ if (last) {
117
+ current[segment.index] = value
118
+ return true
119
+ }
120
+ const existing = current[segment.index]
121
+ if (existing == null || typeof existing !== 'object') {
122
+ if (parsed.segments.slice(i + 1).some((candidate) => candidate.kind === 'id')) return false
123
+ current[segment.index] = newContainer(next)
124
+ }
125
+ current = current[segment.index]
126
+ continue
127
+ }
128
+
129
+ if (segment.kind === 'id') {
130
+ const index = selectId(current, segment.id)
131
+ if (index === -1) return false
132
+ if (last) {
133
+ current[index] = value
134
+ return true
135
+ }
136
+ current = current[index]
137
+ continue
138
+ }
139
+
140
+ return false
57
141
  }
58
- current[keys[keys.length - 1] as string] = value
142
+ return false
143
+ }
144
+
145
+ export function set<T extends object>(object: T, path: string, value: unknown): T {
146
+ setWithResult(object, path, value)
59
147
  return object
60
148
  }