@byline/admin 6.2.1 → 6.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.
Files changed (82) hide show
  1. package/dist/fields/array/array-field.js +29 -12
  2. package/dist/fields/blocks/blocks-field.js +20 -8
  3. package/dist/fields/checkbox/checkbox-field.js +4 -1
  4. package/dist/fields/code/code-field.js +2 -1
  5. package/dist/fields/datetime/datetime-field.js +5 -2
  6. package/dist/fields/field-renderer.js +2 -1
  7. package/dist/fields/field-services-types.d.ts +23 -0
  8. package/dist/fields/file/file-field.js +6 -4
  9. package/dist/fields/group/group-field.js +8 -3
  10. package/dist/fields/image/image-field.js +6 -4
  11. package/dist/fields/numerical/numerical-field.js +2 -1
  12. package/dist/fields/relation/relation-field.js +8 -2
  13. package/dist/fields/relation/relation-many-field.js +12 -4
  14. package/dist/fields/relation/relation-picker.js +38 -7
  15. package/dist/fields/relation/relation-picker.module.js +3 -0
  16. package/dist/fields/relation/relation-picker_module.css +20 -0
  17. package/dist/fields/select/select-field.js +4 -2
  18. package/dist/fields/text/text-field.js +6 -3
  19. package/dist/fields/text-area/text-area-field.js +6 -3
  20. package/dist/fields/use-field-change-handler.js +24 -6
  21. package/dist/fields/use-field-condition.d.ts +0 -15
  22. package/dist/fields/use-field-condition.js +10 -1
  23. package/dist/forms/form-context.d.ts +3 -12
  24. package/dist/forms/form-context.js +115 -130
  25. package/dist/forms/form-dom-scope.d.ts +34 -0
  26. package/dist/forms/form-dom-scope.js +20 -0
  27. package/dist/forms/form-layout.js +10 -3
  28. package/dist/forms/form-page-chrome.js +14 -24
  29. package/dist/forms/form-renderer.d.ts +2 -1
  30. package/dist/forms/form-renderer.js +13 -56
  31. package/dist/forms/nested-path.d.ts +2 -0
  32. package/dist/forms/nested-path.js +26 -1
  33. package/dist/forms/use-document-reload-recovery.d.ts +22 -0
  34. package/dist/forms/use-document-reload-recovery.js +67 -0
  35. package/dist/forms/use-form-submission.d.ts +1 -1
  36. package/dist/forms/use-form-submission.js +45 -27
  37. package/dist/forms/use-form-tabs.d.ts +2 -2
  38. package/dist/forms/use-form-tabs.js +18 -11
  39. package/dist/presentation/tabs.js +2 -1
  40. package/dist/react.d.ts +1 -1
  41. package/package.json +14 -14
  42. package/src/fields/array/array-field.tsx +24 -12
  43. package/src/fields/blocks/blocks-field.tsx +18 -8
  44. package/src/fields/checkbox/checkbox-field.tsx +4 -1
  45. package/src/fields/code/code-field.tsx +2 -1
  46. package/src/fields/datetime/datetime-field.tsx +5 -2
  47. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  48. package/src/fields/field-dom-ids.test.tsx +293 -0
  49. package/src/fields/field-renderer.tsx +2 -1
  50. package/src/fields/field-services-types.ts +25 -0
  51. package/src/fields/file/file-field.tsx +6 -4
  52. package/src/fields/group/group-field.tsx +5 -3
  53. package/src/fields/image/image-field.tsx +6 -4
  54. package/src/fields/numerical/numerical-field.tsx +2 -1
  55. package/src/fields/relation/relation-field.tsx +8 -2
  56. package/src/fields/relation/relation-many-field.tsx +19 -4
  57. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  58. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  59. package/src/fields/relation/relation-picker.module.css +32 -0
  60. package/src/fields/relation/relation-picker.tsx +59 -1
  61. package/src/fields/select/select-field.tsx +4 -2
  62. package/src/fields/text/text-field.tsx +5 -3
  63. package/src/fields/text-area/text-area-field.tsx +5 -3
  64. package/src/fields/use-field-change-handler.ts +43 -9
  65. package/src/fields/use-field-condition.ts +17 -1
  66. package/src/forms/form-context.tsx +180 -234
  67. package/src/forms/form-correctness.test.tsx +560 -0
  68. package/src/forms/form-dom-scope.test.tsx +213 -0
  69. package/src/forms/form-dom-scope.tsx +70 -0
  70. package/src/forms/form-layout.tsx +10 -3
  71. package/src/forms/form-page-chrome.tsx +17 -34
  72. package/src/forms/form-renderer-submit.test.tsx +36 -0
  73. package/src/forms/form-renderer.tsx +26 -64
  74. package/src/forms/form-submit-isolation.test.tsx +247 -0
  75. package/src/forms/nested-path.ts +35 -0
  76. package/src/forms/use-document-reload-recovery.ts +92 -0
  77. package/src/forms/use-form-submission.ts +56 -27
  78. package/src/forms/use-form-tabs.ts +25 -17
  79. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  80. package/src/presentation/tabs.test.tsx +49 -2
  81. package/src/presentation/tabs.tsx +11 -1
  82. package/src/react.ts +2 -0
@@ -27,7 +27,15 @@ import { get as getNestedValue } from '../forms/nested-path'
27
27
  * sibling scope, mirroring `FieldCondition`'s contract.
28
28
  *
29
29
  * Fields without a condition are always visible and no subscription is made.
30
+ *
31
+ * A condition that throws leaves the field visible. It runs on every form edit
32
+ * against live, partly-filled data, so a predicate that assumes a shape the
33
+ * editor has not reached yet would otherwise take the render down. Failing
34
+ * closed keeps the field editable and matches the validation walker, which
35
+ * treats the same failure as visible-and-invalid. The misconfiguration is
36
+ * reported once per field rather than on every keystroke.
30
37
  */
38
+ const reportedConditionFailures = new WeakSet<Field>()
31
39
  export const useFieldCondition = (field: Field, basePath?: string): boolean => {
32
40
  const { getFieldValues, subscribeMeta } = useFormContext()
33
41
 
@@ -35,7 +43,15 @@ export const useFieldCondition = (field: Field, basePath?: string): boolean => {
35
43
  if (!field.condition) return true
36
44
  const data = getFieldValues()
37
45
  const siblingData = basePath ? (getNestedValue(data, basePath) ?? {}) : data
38
- return Boolean(field.condition(data, siblingData))
46
+ try {
47
+ return Boolean(field.condition(data, siblingData))
48
+ } catch (error) {
49
+ if (!reportedConditionFailures.has(field)) {
50
+ reportedConditionFailures.add(field)
51
+ console.error(`Field condition threw for '${field.name}'; keeping the field visible`, error)
52
+ }
53
+ return true
54
+ }
39
55
  }, [field, basePath, getFieldValues])
40
56
 
41
57
  const [visible, setVisible] = useState<boolean>(evaluate)
@@ -9,21 +9,26 @@
9
9
  */
10
10
 
11
11
  import type React from 'react'
12
- import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
12
+ import {
13
+ createContext,
14
+ useCallback,
15
+ useContext,
16
+ useEffect,
17
+ useMemo,
18
+ useRef,
19
+ useSyncExternalStore,
20
+ } from 'react'
13
21
 
14
22
  import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/core'
15
- import { normalizeHooks } from '@byline/core'
23
+ import { normalizeHooks, validateDocumentFields } from '@byline/core'
16
24
  import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
17
25
 
26
+ import { FormDomScopeProvider } from './form-dom-scope'
18
27
  // Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
19
28
  // outright. A bare `from 'lodash-es'` import otherwise pools into a single
20
29
  // ~85KB chunk that leaks onto the public frontend bundle (form-context is
21
30
  // reachable from the layout graph).
22
- import {
23
- get as getNestedValue,
24
- hasExistingIdTargets,
25
- setWithResult as setNestedValue,
26
- } from './nested-path'
31
+ import { get as getNestedValue, hasExistingIdTargets, withValue } from './nested-path'
27
32
  import { deletePendingUploadsUnderPath } from './pending-uploads'
28
33
  import { useTrackedSlot } from './use-tracked-slot'
29
34
 
@@ -118,7 +123,9 @@ interface FormContextType {
118
123
  hasChanges: () => boolean
119
124
  resetHasChanges: () => void
120
125
  runFieldHooks: (fields: Field[]) => Promise<FormError[]>
121
- validateForm: (fields: Field[]) => FormError[]
126
+ validateForm: (fields: Field[], additionalErrors?: FormError[]) => FormError[]
127
+ trackFieldChange: (pending: Promise<void>) => () => void
128
+ waitForFieldChanges: () => Promise<void>
122
129
  errors: FormError[]
123
130
  getErrors: () => FormError[]
124
131
  clearErrors: () => void
@@ -196,7 +203,6 @@ export const FormProvider = ({
196
203
  const fieldValues = useRef<Record<string, any>>(
197
204
  JSON.parse(JSON.stringify(initialData?.fields ?? initialData))
198
205
  )
199
- const initialValues = useRef<Record<string, any>>(initialData?.fields ?? initialData)
200
206
  const errorsRef = useRef<FormError[]>([])
201
207
  const dirtyFields = useRef<Set<string>>(new Set())
202
208
  const patchesRef = useRef<DocumentPatch[]>([])
@@ -287,10 +293,8 @@ export const FormProvider = ({
287
293
 
288
294
  const updateFieldStoreInternal = useCallback(
289
295
  (name: string, value: any) => {
290
- const newFieldValues = { ...fieldValues.current }
291
-
292
- // Keep nested path values up to date for generic usage and patches.
293
- if (!setNestedValue(newFieldValues, name, value)) return false
296
+ const newFieldValues = withValue(fieldValues.current, name, value)
297
+ if (!newFieldValues) return false
294
298
 
295
299
  fieldValues.current = newFieldValues
296
300
  dirtyFields.current.add(name)
@@ -365,18 +369,9 @@ export const FormProvider = ({
365
369
  [notifyMetaListeners]
366
370
  )
367
371
 
368
- const getFieldValue = useCallback((name: string) => {
369
- const dirty = dirtyFields.current.has(name)
370
- const currentValue = getNestedValue(fieldValues.current, name)
371
-
372
- if (currentValue !== undefined) {
373
- return currentValue
374
- }
375
- if (!dirty) {
376
- return getNestedValue(initialValues.current, name)
377
- }
378
- return undefined
379
- }, [])
372
+ // The store starts with the complete initial snapshot. Falling back to that
373
+ // snapshot after an ancestor is cleared would resurrect removed descendants.
374
+ const getFieldValue = useCallback((name: string) => getNestedValue(fieldValues.current, name), [])
380
375
 
381
376
  const hasChanges = useCallback(() => {
382
377
  return dirtyFields.current.size > 0
@@ -538,83 +533,36 @@ export const FormProvider = ({
538
533
  }
539
534
  }, [])
540
535
 
541
- const validateForm = useCallback(
542
- (fields: Field[]): FormError[] => {
543
- const formErrors: FormError[] = []
544
- const data = getFieldValues()
545
-
546
- for (const field of fields) {
547
- // Condition-hidden fields are exempt from client-side validation — a
548
- // field the editor cannot currently see must not block submit. Only
549
- // top-level fields flow through this walk, and a root-level field's
550
- // sibling scope is the form data itself (see FieldCondition).
551
- if (field.condition && !field.condition(data, data)) continue
552
-
553
- const value = getFieldValue(field.name)
554
-
555
- // Required field validation
556
- if (!field.optional && (value == null || value === '')) {
557
- formErrors.push({
558
- field: field.name,
559
- message: `${field.label} is required`,
560
- })
561
- }
562
-
563
- // Type-specific validation
564
- if (value != null && value !== '') {
565
- switch (field.type) {
566
- case 'text':
567
- if (typeof value !== 'string') {
568
- formErrors.push({
569
- field: field.name,
570
- message: `${field.label} must be text`,
571
- })
572
- }
573
- break
574
- case 'checkbox':
575
- if (typeof value !== 'boolean') {
576
- formErrors.push({
577
- field: field.name,
578
- message: `${field.label} must be true or false`,
579
- })
580
- }
581
- break
582
- case 'select':
583
- if ('options' in field && field.options) {
584
- const validValues = field.options.map((opt) => opt.value)
585
- if (!validValues.includes(value)) {
586
- formErrors.push({
587
- field: field.name,
588
- message: `${field.label} must be one of: ${validValues.join(', ')}`,
589
- })
590
- }
591
- }
592
- break
593
- case 'datetime':
594
- if (value instanceof Date === false && typeof value !== 'string') {
595
- formErrors.push({
596
- field: field.name,
597
- message: `${field.label} must be a valid date`,
598
- })
599
- }
600
- break
601
- }
602
- }
603
-
604
- // Custom validate function — applies to all field types including structure fields.
605
- if (field.validate) {
606
- const error = field.validate(value, data)
607
- if (error) {
608
- formErrors.push({ field: field.name, message: error })
609
- }
610
- }
536
+ const fieldChanges = useRef(new Set<Promise<void>>())
537
+ const fieldChangeWaiters = useRef(new Set<() => void>())
538
+ const trackFieldChange = useCallback((pending: Promise<void>) => {
539
+ fieldChanges.current.add(pending)
540
+ const release = () => {
541
+ fieldChanges.current.delete(pending)
542
+ if (fieldChanges.current.size === 0) {
543
+ for (const resolve of fieldChangeWaiters.current) resolve()
544
+ fieldChangeWaiters.current.clear()
611
545
  }
546
+ }
547
+ void pending.then(release, release)
548
+ return release
549
+ }, [])
550
+ const waitForFieldChanges = useCallback(async () => {
551
+ while (fieldChanges.current.size)
552
+ await new Promise<void>((resolve) => fieldChangeWaiters.current.add(resolve))
553
+ }, [])
612
554
 
613
- errorsRef.current = formErrors
555
+ const validateForm = useCallback(
556
+ (fields: Field[], additionalErrors: FormError[] = []): FormError[] => {
557
+ const formErrors = validateDocumentFields(fields, getFieldValues(), {
558
+ respectConditions: true,
559
+ skip: (path) => pendingUploadsRef.current.has(path),
560
+ })
561
+ errorsRef.current = [...additionalErrors, ...formErrors]
614
562
  notifyErrorListeners()
615
- return formErrors
563
+ return errorsRef.current
616
564
  },
617
- [getFieldValue, getFieldValues, notifyErrorListeners]
565
+ [getFieldValues, notifyErrorListeners]
618
566
  )
619
567
 
620
568
  const clearErrors = useCallback(() => {
@@ -658,8 +606,20 @@ export const FormProvider = ({
658
606
  if (fns.length === 0) continue
659
607
 
660
608
  // Condition-hidden fields skip submit-time hooks, mirroring their
661
- // exemption from validateForm below.
662
- if (field.condition && !field.condition(data, data)) continue
609
+ // exemption from validateForm below. A predicate that throws leaves the
610
+ // field visible, matching the render hook and the validation walker: the
611
+ // hooks run, and validateForm reports the failure as a field error
612
+ // rather than the exception rejecting submission before anything can be
613
+ // shown.
614
+ if (field.condition) {
615
+ let visible = true
616
+ try {
617
+ visible = Boolean(field.condition(data, data))
618
+ } catch {
619
+ // Reported by validateForm, which evaluates the same predicate.
620
+ }
621
+ if (!visible) continue
622
+ }
663
623
 
664
624
  const path = field.name
665
625
  const value = getFieldValue(path)
@@ -704,52 +664,101 @@ export const FormProvider = ({
704
664
  [getFieldValue, setFieldValue, notifyErrorListeners]
705
665
  )
706
666
 
667
+ const resetPatches = useCallback(() => {
668
+ patchesRef.current = []
669
+ }, [])
670
+ const getErrors = useCallback(() => errorsRef.current, [])
671
+ const context = useMemo(
672
+ () => ({
673
+ documentId,
674
+ collectionPath,
675
+ setFieldValue,
676
+ setFieldStore,
677
+ getFieldValue,
678
+ getFieldValues,
679
+ getPatches,
680
+ appendPatch,
681
+ resetPatches,
682
+ hasChanges,
683
+ resetHasChanges,
684
+ runFieldHooks,
685
+ validateForm,
686
+ get errors() {
687
+ return errorsRef.current
688
+ },
689
+ getErrors,
690
+ trackFieldChange,
691
+ waitForFieldChanges,
692
+ clearErrors,
693
+ setFieldError,
694
+ clearFieldError,
695
+ isDirty,
696
+ getDirtyBreakdown,
697
+ subscribeField,
698
+ subscribeErrors,
699
+ subscribeMeta,
700
+ addPendingUpload,
701
+ removePendingUpload,
702
+ removePendingUploadsUnder,
703
+ getPendingUploads,
704
+ hasPendingUploads,
705
+ clearPendingUploads,
706
+ setFieldUploading,
707
+ getIsFieldUploading,
708
+ subscribeFieldUploading,
709
+ getSystemPath: pathSlot.get,
710
+ setSystemPath: pathSlot.set,
711
+ subscribeSystemPath: pathSlot.subscribe,
712
+ getSystemAvailableLocales: availableLocalesSlot.get,
713
+ setSystemAvailableLocales: availableLocalesSlot.set,
714
+ subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
715
+ }),
716
+ [
717
+ documentId,
718
+ collectionPath,
719
+ setFieldValue,
720
+ setFieldStore,
721
+ getFieldValue,
722
+ getFieldValues,
723
+ getPatches,
724
+ appendPatch,
725
+ resetPatches,
726
+ hasChanges,
727
+ resetHasChanges,
728
+ runFieldHooks,
729
+ validateForm,
730
+ getErrors,
731
+ trackFieldChange,
732
+ waitForFieldChanges,
733
+ clearErrors,
734
+ setFieldError,
735
+ clearFieldError,
736
+ isDirty,
737
+ getDirtyBreakdown,
738
+ subscribeField,
739
+ subscribeErrors,
740
+ subscribeMeta,
741
+ addPendingUpload,
742
+ removePendingUpload,
743
+ removePendingUploadsUnder,
744
+ getPendingUploads,
745
+ hasPendingUploads,
746
+ clearPendingUploads,
747
+ setFieldUploading,
748
+ getIsFieldUploading,
749
+ subscribeFieldUploading,
750
+ pathSlot.get,
751
+ pathSlot.set,
752
+ pathSlot.subscribe,
753
+ availableLocalesSlot.get,
754
+ availableLocalesSlot.set,
755
+ availableLocalesSlot.subscribe,
756
+ ]
757
+ )
758
+
707
759
  return (
708
- <FormContext.Provider
709
- value={{
710
- documentId,
711
- collectionPath,
712
- setFieldValue,
713
- setFieldStore,
714
- getFieldValue,
715
- getFieldValues,
716
- getPatches,
717
- appendPatch,
718
- resetPatches: () => {
719
- patchesRef.current = []
720
- },
721
- hasChanges,
722
- resetHasChanges,
723
- runFieldHooks,
724
- validateForm,
725
- errors: errorsRef.current,
726
- getErrors: () => errorsRef.current,
727
- clearErrors,
728
- setFieldError,
729
- clearFieldError,
730
- isDirty,
731
- getDirtyBreakdown,
732
- subscribeField,
733
- subscribeErrors,
734
- subscribeMeta,
735
- addPendingUpload,
736
- removePendingUpload,
737
- removePendingUploadsUnder,
738
- getPendingUploads,
739
- hasPendingUploads,
740
- clearPendingUploads,
741
- setFieldUploading,
742
- getIsFieldUploading,
743
- subscribeFieldUploading,
744
- getSystemPath: pathSlot.get,
745
- setSystemPath: pathSlot.set,
746
- subscribeSystemPath: pathSlot.subscribe,
747
- getSystemAvailableLocales: availableLocalesSlot.get,
748
- setSystemAvailableLocales: availableLocalesSlot.set,
749
- subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
750
- }}
751
- >
752
- {children}
760
+ <FormContext.Provider value={context}>
761
+ <FormDomScopeProvider>{children}</FormDomScopeProvider>
753
762
  </FormContext.Provider>
754
763
  )
755
764
  }
@@ -760,117 +769,54 @@ export const FormProvider = ({
760
769
  */
761
770
  export const useSystemPath = (): string | null => {
762
771
  const { getSystemPath, subscribeSystemPath } = useFormContext()
763
- const [value, setValue] = useState<string | null>(() => getSystemPath())
764
-
765
- useEffect(() => {
766
- return subscribeSystemPath((next) => setValue(next))
767
- }, [subscribeSystemPath])
768
-
769
- return value
772
+ return useSyncExternalStore(subscribeSystemPath, getSystemPath, getSystemPath)
770
773
  }
771
774
 
772
- /**
773
- * Subscribe to the system `availableLocales` slot edited by the
774
- * available-locales widget. Returns the current advertised set (or `[]` when
775
- * nothing is advertised / not yet surfaced).
776
- */
777
775
  export const useSystemAvailableLocales = (): string[] => {
778
776
  const { getSystemAvailableLocales, subscribeSystemAvailableLocales } = useFormContext()
779
- const [value, setValue] = useState<string[]>(() => getSystemAvailableLocales())
780
-
781
- useEffect(() => {
782
- return subscribeSystemAvailableLocales((next) => setValue(next))
783
- }, [subscribeSystemAvailableLocales])
784
-
785
- return value
777
+ return useSyncExternalStore(
778
+ subscribeSystemAvailableLocales,
779
+ getSystemAvailableLocales,
780
+ getSystemAvailableLocales
781
+ )
786
782
  }
787
783
 
788
- export const useFormStore = () => {
789
- return useFormContext()
790
- }
784
+ export const useFormStore = () => useFormContext()
791
785
 
792
786
  export const useFieldError = (name: string) => {
793
787
  const { getErrors, subscribeErrors } = useFormContext()
794
- // Seed from the live errors ref via getErrors() rather than the context's
795
- // `errors` snapshot — the snapshot is bound at FormProvider's first render
796
- // and goes stale as soon as validateForm replaces errorsRef.current. Fields
797
- // mounted after validation has already run (e.g. switching to a tab whose
798
- // error badge is non-zero) would otherwise initialise to undefined and miss
799
- // the existing error until something else fires notifyErrorListeners.
800
- const [error, setError] = useState<string | undefined>(
801
- () => getErrors().find((e) => e.field === name)?.message
802
- )
803
-
804
- useEffect(() => {
805
- const unsubscribe = subscribeErrors((currentErrors) => {
806
- const fieldError = currentErrors.find((e) => e.field === name)
807
- setError(fieldError?.message)
808
- })
809
- return unsubscribe
810
- }, [subscribeErrors, name])
811
-
812
- return error
788
+ const errors = useSyncExternalStore(subscribeErrors, getErrors, getErrors)
789
+ return errors.find((error) => error.field === name)?.message
813
790
  }
814
791
 
815
792
  export const useFormMeta = () => {
816
793
  const { hasChanges, subscribeMeta } = useFormContext()
817
- const [hasChangesValue, setHasChangesValue] = useState(hasChanges())
818
-
819
- useEffect(() => {
820
- const unsubscribe = subscribeMeta(() => {
821
- setHasChangesValue(hasChanges())
822
- })
823
- return unsubscribe
824
- }, [subscribeMeta, hasChanges])
825
-
826
- return {
827
- hasChanges: hasChangesValue,
828
- }
794
+ return { hasChanges: useSyncExternalStore(subscribeMeta, hasChanges, hasChanges) }
829
795
  }
830
796
 
831
797
  export const useIsDirty = (name: string) => {
832
798
  const { isDirty, subscribeMeta } = useFormContext()
833
- const [dirty, setDirty] = useState(isDirty(name))
834
-
835
- useEffect(() => {
836
- const unsubscribe = subscribeMeta(() => {
837
- setDirty(isDirty(name))
838
- })
839
- return unsubscribe
840
- }, [subscribeMeta, isDirty, name])
841
-
842
- return dirty
799
+ const snapshot = useCallback(() => isDirty(name), [isDirty, name])
800
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot)
843
801
  }
844
802
 
845
803
  export const useFieldValue = <T = any>(name: string): T | undefined => {
846
- const { getFieldValue, subscribeField } = useFormContext()
847
- const [value, setValue] = useState<T | undefined>(() => getFieldValue(name))
848
-
849
- useEffect(() => {
850
- const unsubscribe = subscribeField(name, (nextValue) => {
851
- setValue(nextValue)
852
- })
853
- return unsubscribe
854
- }, [subscribeField, name])
855
-
856
- return value
804
+ const { getFieldValue, subscribeMeta } = useFormContext()
805
+ const snapshot = useCallback(() => getFieldValue(name), [getFieldValue, name])
806
+ // Ancestor replacement and descendant edits both affect a composite value.
807
+ // Structural sharing keeps unrelated field snapshots referentially stable.
808
+ return useSyncExternalStore(subscribeMeta, snapshot, snapshot)
857
809
  }
858
810
 
859
- /**
860
- * Subscribe to a single field's upload-in-flight state. Returns `true` while
861
- * the form orchestrator is actively transporting this field's pending upload
862
- * (between the `setFieldUploading(path, true)` and the matching `false`
863
- * emitted by the upload executor's progress callback).
864
- */
865
811
  export const useIsFieldUploading = (fieldPath: string): boolean => {
866
812
  const { getIsFieldUploading, subscribeFieldUploading } = useFormContext()
867
- const [uploading, setUploading] = useState<boolean>(() => getIsFieldUploading(fieldPath))
868
-
869
- useEffect(() => {
870
- return subscribeFieldUploading(fieldPath, (next) => {
871
- setUploading(next)
872
- })
873
- }, [subscribeFieldUploading, fieldPath])
874
-
875
- return uploading
813
+ const subscribe = useCallback(
814
+ (notify: () => void) => subscribeFieldUploading(fieldPath, notify),
815
+ [subscribeFieldUploading, fieldPath]
816
+ )
817
+ const snapshot = useCallback(
818
+ () => getIsFieldUploading(fieldPath),
819
+ [getIsFieldUploading, fieldPath]
820
+ )
821
+ return useSyncExternalStore(subscribe, snapshot, snapshot)
876
822
  }