@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
@@ -0,0 +1,213 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { act } from 'react'
10
+
11
+ import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
12
+ import { renderToString } from 'react-dom/server'
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
14
+
15
+ import { FormProvider } from './form-context'
16
+ import { useFormDomScope, useScopedDomId } from './form-dom-scope'
17
+
18
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
19
+
20
+ describe('form DOM scope', () => {
21
+ let container: HTMLDivElement
22
+ let root: Root
23
+
24
+ beforeEach(() => {
25
+ container = document.createElement('div')
26
+ document.body.appendChild(container)
27
+ root = createRoot(container)
28
+ })
29
+
30
+ afterEach(() => {
31
+ act(() => {
32
+ root.unmount()
33
+ })
34
+ container.remove()
35
+ })
36
+
37
+ const render = (node: React.ReactNode) => {
38
+ act(() => {
39
+ root.render(node)
40
+ })
41
+ }
42
+
43
+ it('gives two concurrently mounted providers different scopes', () => {
44
+ const scopes: string[] = []
45
+ const Probe = () => {
46
+ scopes.push(useFormDomScope())
47
+ return null
48
+ }
49
+
50
+ render(
51
+ <>
52
+ <FormProvider initialData={{}}>
53
+ <Probe />
54
+ </FormProvider>
55
+ <FormProvider initialData={{}}>
56
+ <Probe />
57
+ </FormProvider>
58
+ </>
59
+ )
60
+
61
+ expect(scopes).toHaveLength(2)
62
+ expect(new Set(scopes).size).toBe(2)
63
+ })
64
+
65
+ it('derives a stable id for the same base across re-renders', () => {
66
+ const seen: string[] = []
67
+ const Probe = () => {
68
+ seen.push(useScopedDomId('title'))
69
+ return null
70
+ }
71
+
72
+ render(
73
+ <FormProvider initialData={{}}>
74
+ <Probe />
75
+ </FormProvider>
76
+ )
77
+ render(
78
+ <FormProvider initialData={{}}>
79
+ <Probe />
80
+ </FormProvider>
81
+ )
82
+
83
+ expect(seen.length).toBeGreaterThanOrEqual(2)
84
+ expect(new Set(seen).size).toBe(1)
85
+ })
86
+
87
+ it('scopes the base so two forms derive different ids from one field name', () => {
88
+ const ids: string[] = []
89
+ const Probe = () => {
90
+ ids.push(useScopedDomId('title'))
91
+ return null
92
+ }
93
+
94
+ render(
95
+ <>
96
+ <FormProvider initialData={{}}>
97
+ <Probe />
98
+ </FormProvider>
99
+ <FormProvider initialData={{}}>
100
+ <Probe />
101
+ </FormProvider>
102
+ </>
103
+ )
104
+
105
+ expect(new Set(ids).size).toBe(2)
106
+ for (const id of ids) expect(id.endsWith('title')).toBe(true)
107
+ })
108
+
109
+ it('returns a caller-supplied id unchanged rather than scoping it twice', () => {
110
+ let derived = ''
111
+ const Probe = () => {
112
+ derived = useScopedDomId('title', 'explicit-id')
113
+ return null
114
+ }
115
+
116
+ render(
117
+ <FormProvider initialData={{}}>
118
+ <Probe />
119
+ </FormProvider>
120
+ )
121
+
122
+ expect(derived).toBe('explicit-id')
123
+ })
124
+
125
+ /**
126
+ * Field widgets render outside a form provider in places — the admin user and
127
+ * role screens compose them directly — so the hook has to degrade to the bare
128
+ * base rather than throw or invent a scope.
129
+ */
130
+ it('falls back to the bare base outside a provider', () => {
131
+ let derived = ''
132
+ let scope = 'unset'
133
+ const Probe = () => {
134
+ scope = useFormDomScope()
135
+ derived = useScopedDomId('title')
136
+ return null
137
+ }
138
+
139
+ render(<Probe />)
140
+
141
+ expect(scope).toBe('')
142
+ expect(derived).toBe('title')
143
+ })
144
+
145
+ it('produces a non-empty scope inside a provider', () => {
146
+ const scopes: string[] = []
147
+ const Probe = () => {
148
+ scopes.push(useFormDomScope())
149
+ return null
150
+ }
151
+
152
+ render(
153
+ <FormProvider initialData={{}}>
154
+ <Probe />
155
+ </FormProvider>
156
+ )
157
+
158
+ expect(scopes[0]).not.toBe('')
159
+ })
160
+
161
+ /**
162
+ * The reason the scope comes from `useId` rather than a counter or a random
163
+ * value: the server render and the hydration pass must agree. If they do not,
164
+ * React discards the server-rendered attributes and every `for` and
165
+ * `aria-labelledby` written during SSR points at an id that no longer exists.
166
+ *
167
+ * This renders on the server for real and hydrates that markup, rather than
168
+ * inspecting the shape of the generated string.
169
+ */
170
+ it('derives the same id on the server render and on hydration', async () => {
171
+ let renderedId = ''
172
+ const Probe = () => {
173
+ const id = useScopedDomId('title')
174
+ renderedId = id
175
+ return <span id={id}>title</span>
176
+ }
177
+ const tree = (
178
+ <FormProvider initialData={{}}>
179
+ <Probe />
180
+ </FormProvider>
181
+ )
182
+
183
+ const html = renderToString(tree)
184
+ const serverId = /id="([^"]+)"/.exec(html)?.[1]
185
+ expect(serverId).toBeTruthy()
186
+
187
+ const host = document.createElement('div')
188
+ host.innerHTML = html
189
+ document.body.appendChild(host)
190
+
191
+ // React reports a hydration mismatch through console.error rather than by
192
+ // throwing, so silence alone is not evidence — collect and assert on it.
193
+ const consoleErrors: unknown[][] = []
194
+ const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
195
+ consoleErrors.push(args)
196
+ })
197
+
198
+ let hydrated: Root | undefined
199
+ await act(async () => {
200
+ hydrated = hydrateRoot(host, tree)
201
+ })
202
+ spy.mockRestore()
203
+
204
+ expect(consoleErrors).toEqual([])
205
+ expect(renderedId).toBe(serverId)
206
+ expect(host.querySelector('span')?.id).toBe(serverId)
207
+
208
+ act(() => {
209
+ hydrated?.unmount()
210
+ })
211
+ host.remove()
212
+ })
213
+ })
@@ -0,0 +1,70 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import type { ReactNode } from 'react'
12
+ import { createContext, useContext, useId } from 'react'
13
+
14
+ /**
15
+ * Per-form-instance prefix for derived DOM ids.
16
+ *
17
+ * Field widgets derive an element id from the field's path, and tab strips derive
18
+ * theirs from the tab-set name. Both are unique within one form and identical
19
+ * between two forms editing the same collection. Mount a second form — the
20
+ * relationship picker's creation view does exactly that — and every `label`'s
21
+ * `for`, every `aria-describedby` and every `aria-labelledby` resolves to whichever
22
+ * element the browser happens to see first, which is the parent form's. The reader
23
+ * then gets the wrong label announced and a click on a label focuses a field in a
24
+ * different form.
25
+ *
26
+ * `null` rather than `''` as the default so `useFormDomScope` can tell "no provider
27
+ * above me" from "a provider that produced an empty scope", even though both
28
+ * currently degrade to the same unprefixed id.
29
+ */
30
+ const FormDomScopeContext = createContext<string | null>(null)
31
+
32
+ /**
33
+ * Establishes one DOM id scope. `FormProvider` wraps its children in this, so a
34
+ * scope exists wherever form state does and no caller has to opt in.
35
+ */
36
+ export const FormDomScopeProvider = ({ children }: { children: ReactNode }): ReactNode => {
37
+ // `useId` is the only generator that agrees between the server render and
38
+ // hydration. A counter or a random value produces different ids on each side,
39
+ // React discards the server-rendered attributes, and the `for`/`aria-*` pairings
40
+ // written during SSR are left pointing at ids that no longer exist.
41
+ const scope = useId()
42
+ return <FormDomScopeContext.Provider value={scope}>{children}</FormDomScopeContext.Provider>
43
+ }
44
+
45
+ /**
46
+ * The active scope, or `''` when rendered outside a form. Field widgets are also
47
+ * composed directly by the admin user and role screens, which have no
48
+ * `FormProvider`, so an absent scope degrades to the unprefixed id rather than
49
+ * throwing.
50
+ */
51
+ export function useFormDomScope(): string {
52
+ return useContext(FormDomScopeContext) ?? ''
53
+ }
54
+
55
+ /**
56
+ * Scope a derived DOM id.
57
+ *
58
+ * A caller-supplied `id` is returned untouched: it is the author's identifier, it
59
+ * may already be referenced from outside the form, and scoping it would break that
60
+ * reference as well as double-prefix ids that pass through here twice.
61
+ *
62
+ * The result is a valid HTML id and a valid `for` / `aria-*` reference, but React's
63
+ * generated scope contains characters that need escaping in a CSS selector. Resolve
64
+ * these with `document.getElementById`, not `querySelector('#…')`.
65
+ */
66
+ export function useScopedDomId(base: string, explicit?: string): string {
67
+ const scope = useFormDomScope()
68
+ if (explicit != null) return explicit
69
+ return scope === '' ? base : `${scope}${base}`
70
+ }
@@ -8,7 +8,7 @@
8
8
  * Copyright (c) Infonomic Company Limited
9
9
  */
10
10
 
11
- import { Fragment, type ReactNode } from 'react'
11
+ import { Fragment, type ReactNode, useMemo } from 'react'
12
12
 
13
13
  import type {
14
14
  AdminResourceConfig,
@@ -24,6 +24,7 @@ import { FieldRenderer } from '../fields/field-renderer'
24
24
  import { AdminGroup } from '../presentation/group'
25
25
  import { AdminRow } from '../presentation/row'
26
26
  import { AdminTabs, tabPanelId, tabTriggerId } from '../presentation/tabs'
27
+ import { useFormDomScope } from './form-dom-scope'
27
28
  import styles from './form-renderer.module.css'
28
29
  import { useFormLayout } from './use-form-layout'
29
30
  import { useFormTabs } from './use-form-tabs'
@@ -74,7 +75,13 @@ export const FormLayout = ({
74
75
  }: FormLayoutProps): ReactNode => {
75
76
  const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
76
77
  useFormLayout(adminConfig, fields)
77
- const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
78
+ const tabSets = useMemo(() => [...tabSetByName.values()], [tabSetByName])
79
+ const tabs = useFormTabs(fieldToTabPath, activeTabBySet, tabSets)
80
+ // Two forms over one collection share its admin configuration, so their tab
81
+ // sets carry the same name and would otherwise derive the same trigger and
82
+ // panel ids. `aria-labelledby` resolves document-wide, so the second form's
83
+ // panel would name the first form's trigger.
84
+ const domScope = useFormDomScope()
78
85
 
79
86
  const renderField = (fieldName: string): ReactNode => {
80
87
  const field = fieldByName.get(fieldName)
@@ -117,7 +124,7 @@ export const FormLayout = ({
117
124
 
118
125
  const renderTabSet = (set: TabSetDefinition): ReactNode => {
119
126
  const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set)
120
- const idBase = `tabset:${set.name}`
127
+ const idBase = `${domScope}tabset:${set.name}`
121
128
 
122
129
  return (
123
130
  <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
@@ -117,6 +117,21 @@ export const FormStatusBar = ({
117
117
  }: FormStatusBarProps): ReactNode => {
118
118
  const { t } = useTranslation('byline-admin')
119
119
  const { primaryStatus, secondaryStatuses, isTerminal } = transitions
120
+ const runTransition = async (status: string) => {
121
+ if (isBlocked() || !onStatusChange) return
122
+ if (hasChanges) {
123
+ onUnsavedChanges()
124
+ return
125
+ }
126
+ onStatusBusyChange(true)
127
+ try {
128
+ await onStatusChange(status)
129
+ } catch (error) {
130
+ onMutationError?.(error)
131
+ } finally {
132
+ onStatusBusyChange(false)
133
+ }
134
+ }
120
135
 
121
136
  return (
122
137
  <div className={cx('byline-form-status-bar', styles['status-bar'])}>
@@ -192,40 +207,8 @@ export const FormStatusBar = ({
192
207
  type="button"
193
208
  intent={isTerminal ? 'info' : 'success'}
194
209
  disabled={disabled || statusBusy}
195
- onOptionSelect={async (value: string) => {
196
- if (isBlocked()) return
197
- if (hasChanges) {
198
- onUnsavedChanges()
199
- return
200
- }
201
- onStatusBusyChange(true)
202
- try {
203
- await onStatusChange(value)
204
- } catch (error) {
205
- onMutationError?.(error)
206
- } finally {
207
- onStatusBusyChange(false)
208
- }
209
- }}
210
- onButtonClick={
211
- isTerminal
212
- ? undefined
213
- : async () => {
214
- if (isBlocked()) return
215
- if (hasChanges) {
216
- onUnsavedChanges()
217
- return
218
- }
219
- onStatusBusyChange(true)
220
- try {
221
- await onStatusChange(primaryStatus.name)
222
- } catch (error) {
223
- onMutationError?.(error)
224
- } finally {
225
- onStatusBusyChange(false)
226
- }
227
- }
228
- }
210
+ onOptionSelect={runTransition}
211
+ onButtonClick={isTerminal ? undefined : () => runTransition(primaryStatus.name)}
229
212
  >
230
213
  {statusBusy
231
214
  ? '...'
@@ -306,6 +306,42 @@ describe('FormRenderer submit contract', () => {
306
306
  document.body.removeAttribute('tabindex')
307
307
  })
308
308
 
309
+ it('starts a clean baseline after a successful save installs a new document version', async () => {
310
+ const onSubmit = vi.fn(async () => {})
311
+ const props = {
312
+ ...baseProps,
313
+ mode: 'edit',
314
+ onSubmit,
315
+ initialData: { id: 'doc', versionId: 'v1', revision: 1, fields: { title: 'Original' } },
316
+ }
317
+ render(props)
318
+ typeIntoTitle('Saved')
319
+ submitForm()
320
+ await act(async () => {})
321
+ expect(onSubmit.mock.calls).toHaveLength(1)
322
+ render({
323
+ ...props,
324
+ initialData: {
325
+ ...props.initialData,
326
+ versionId: 'v2',
327
+ revision: 2,
328
+ fields: { title: 'Saved' },
329
+ },
330
+ })
331
+ expect(container.querySelector<HTMLInputElement>('input[name="title"]')?.value).toBe('Saved')
332
+ typeIntoTitle('Next edit')
333
+ submitForm()
334
+ await act(async () => {})
335
+ const payload = (onSubmit.mock.calls as unknown as Array<[any]>)[1]?.[0]
336
+ expect(payload.data.title).toBe('Next edit')
337
+ expect(payload.patches).toHaveLength(1)
338
+ expect(payload.patches[0]).toMatchObject({
339
+ kind: 'field.set',
340
+ path: 'title',
341
+ value: 'Next edit',
342
+ })
343
+ })
344
+
309
345
  it('ignores a second submit while the first is still in flight', async () => {
310
346
  const submission = deferred()
311
347
  const onSubmit = vi.fn(async () => {
@@ -33,7 +33,8 @@ import {
33
33
  useScheduledPublication,
34
34
  } from './scheduled-publication-control'
35
35
  import { computeStatusTransitions } from './status-transitions'
36
- import { useFormSubmission } from './use-form-submission'
36
+ import { useDocumentReloadRecovery } from './use-document-reload-recovery'
37
+ import { type SystemFieldsSubmitPayload, useFormSubmission } from './use-form-submission'
37
38
  import type { DocumentActionsLocaleOption } from './document-actions'
38
39
  import type { UseNavigationGuard } from './navigation-guard'
39
40
 
@@ -63,7 +64,7 @@ export interface FormRendererProps {
63
64
  onReloadDocument?: () => void | Promise<void>
64
65
  mode: 'create' | 'edit'
65
66
  fields: Field[]
66
- onSubmit: (data: any) => void | Promise<void>
67
+ onSubmit: (data: SystemFieldsSubmitPayload) => void | Promise<void>
67
68
  onCancel: () => void
68
69
  onStatusChange?: (nextStatus: string) => Promise<void>
69
70
  onUnpublish?: () => Promise<void>
@@ -238,9 +239,6 @@ const FormContent = ({
238
239
 
239
240
  const [hasChanges, setHasChanges] = useState(hasChangesFn())
240
241
  const [statusBusy, setStatusBusy] = useState(false)
241
- const formRef = useRef<HTMLFormElement>(null)
242
- const focusBeforeBusyRef = useRef<HTMLElement | null>(null)
243
- const restoreFocusAfterBusyRef = useRef(false)
244
242
  // Block-only "save first" guard. Set true when the editor triggers a
245
243
  // guarded action (status change, duplicate, copy-to-locale) while the form
246
244
  // is dirty — those actions operate on the saved version, so unsaved edits
@@ -332,26 +330,6 @@ const FormContent = ({
332
330
  // The guard hook is injected by the consuming framework (prop > context > no-op fallback).
333
331
  const guardFromContext = useNavigationGuardAdapter()
334
332
  const useGuard = useNavigationGuardProp ?? guardFromContext
335
- const [discarding, setDiscarding] = useState(false)
336
- const [reloadFailed, setReloadFailed] = useState(false)
337
- const warningRef = useRef<HTMLDivElement>(null)
338
- const mutationBlockedRef = useRef(mutationsBlocked)
339
- mutationBlockedRef.current = mutationsBlocked || discarding
340
- const guard = useGuard(hasChanges && !discarding)
341
- useEffect(() => {
342
- if (mutationIssue) warningRef.current?.focus()
343
- }, [mutationIssue])
344
- useEffect(() => {
345
- if (!discarding) return
346
- // Let the guard's beforeunload listener detach before the explicit discard.
347
- Promise.resolve()
348
- .then(() => (onReloadDocument ? onReloadDocument() : window.location.reload()))
349
- .catch(() => {
350
- setDiscarding(false)
351
- setReloadFailed(true)
352
- })
353
- }, [discarding, onReloadDocument])
354
-
355
333
  // Compute available status transitions
356
334
  const currentStatus = initialData?.status
357
335
  const { primaryStatus, secondaryStatuses, isTerminal } = computeStatusTransitions(
@@ -364,14 +342,6 @@ const FormContent = ({
364
342
  return subscribeMeta(() => setHasChanges(hasChangesFn()))
365
343
  }, [subscribeMeta, hasChangesFn])
366
344
 
367
- const captureFocusBeforeBusy = useCallback(() => {
368
- if (focusBeforeBusyRef.current != null) return
369
- const activeElement = document.activeElement
370
- if (!(activeElement instanceof HTMLElement) || !formRef.current?.contains(activeElement)) return
371
- focusBeforeBusyRef.current = activeElement
372
- restoreFocusAfterBusyRef.current = true
373
- }, [])
374
-
375
345
  // One save at a time: validate -> upload -> (confirm) -> submit. Admission is
376
346
  // decided synchronously inside the hook, so two Saves in the same turn cannot
377
347
  // both get through. `phase` is what the UI renders.
@@ -381,40 +351,24 @@ const FormContent = ({
381
351
  documentId: mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : undefined,
382
352
  advertiseLocales,
383
353
  onSubmit,
384
- isBlocked: () => mutationBlockedRef.current,
385
- onBeforeBusy: captureFocusBeforeBusy,
354
+ isBlocked: (): boolean => recovery.mutationBlockedRef.current,
355
+ onBeforeBusy: (): void => recovery.captureFocusBeforeBusy(),
356
+ })
357
+ const recovery = useDocumentReloadRecovery({
358
+ mutationsBlocked,
359
+ mutationIssue,
360
+ onReloadDocument,
361
+ isBusy: submission.isBusy,
386
362
  })
363
+ const { formRef, warningRef, mutationBlockedRef, discarding, reloadFailed, requestDiscard } =
364
+ recovery
365
+ const guard = useGuard(hasChanges && !discarding)
387
366
  const isUploading = submission.phase.kind === 'uploading'
388
367
  const isSubmitting = submission.phase.kind === 'submitting'
389
368
  const isBusy = submission.isBusy
390
369
  const pendingSystemFieldsSubmit =
391
370
  submission.phase.kind === 'confirmingSystemFields' ? submission.phase.payload : null
392
371
 
393
- // `inert` removes the active control from the tab order while a save is in
394
- // flight. Restore the editor's position after React has removed `inert`;
395
- // when the original control became disabled, fall back to the first usable
396
- // form control instead of leaving focus on <body>.
397
- useEffect(() => {
398
- if (isBusy || !restoreFocusAfterBusyRef.current) return
399
- restoreFocusAfterBusyRef.current = false
400
-
401
- if (mutationIssue) {
402
- warningRef.current?.focus()
403
- focusBeforeBusyRef.current = null
404
- return
405
- }
406
- const original = focusBeforeBusyRef.current
407
- focusBeforeBusyRef.current = null
408
- const originalCanReceiveFocus =
409
- original?.isConnected === true && !original.matches(':disabled, [aria-disabled="true"]')
410
- const target = originalCanReceiveFocus
411
- ? original
412
- : formRef.current?.querySelector<HTMLElement>(
413
- 'input:not(:disabled), textarea:not(:disabled), select:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])'
414
- )
415
- target?.focus({ preventScroll: true })
416
- }, [isBusy, mutationIssue])
417
-
418
372
  const handleCancel = () => {
419
373
  if (onCancel && typeof onCancel === 'function') {
420
374
  onCancel()
@@ -422,6 +376,17 @@ const FormContent = ({
422
376
  }
423
377
 
424
378
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
379
+ // A form rendered into a portal is not a DOM descendant of this one, so no
380
+ // invalid nested-form markup arises — but React propagates synthetic events
381
+ // along the *React* tree, so its submit still reaches this handler. Nothing
382
+ // about `currentTarget` reveals that: it is always this form while the
383
+ // handler runs, bubbled event or not. The event's origin is what
384
+ // distinguishes them.
385
+ //
386
+ // Deliberately no `preventDefault()` on the way out: the submit belongs to
387
+ // the inner form, and suppressing its default here would take that decision
388
+ // away from the handler that owns it.
389
+ if (e.target !== e.currentTarget) return
425
390
  e.preventDefault()
426
391
  if (mutationBlockedRef.current) return
427
392
  void submission.submit()
@@ -487,10 +452,7 @@ const FormContent = ({
487
452
  warningRef={warningRef}
488
453
  discarding={discarding}
489
454
  reloadFailed={reloadFailed}
490
- onDiscardRequested={() => {
491
- setReloadFailed(false)
492
- setDiscarding(true)
493
- }}
455
+ onDiscardRequested={requestDiscard}
494
456
  scheduling={scheduling}
495
457
  restoreWarnings={restoreWarnings}
496
458
  />