@byline/admin 6.2.1 → 6.3.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 (55) hide show
  1. package/dist/fields/checkbox/checkbox-field.js +3 -1
  2. package/dist/fields/code/code-field.js +2 -1
  3. package/dist/fields/datetime/datetime-field.js +4 -2
  4. package/dist/fields/field-renderer.js +2 -1
  5. package/dist/fields/field-services-types.d.ts +23 -0
  6. package/dist/fields/file/file-field.js +3 -2
  7. package/dist/fields/group/group-field.js +3 -1
  8. package/dist/fields/image/image-field.js +3 -2
  9. package/dist/fields/numerical/numerical-field.js +2 -1
  10. package/dist/fields/relation/relation-field.js +3 -2
  11. package/dist/fields/relation/relation-many-field.js +3 -2
  12. package/dist/fields/relation/relation-picker.js +38 -7
  13. package/dist/fields/relation/relation-picker.module.js +3 -0
  14. package/dist/fields/relation/relation-picker_module.css +20 -0
  15. package/dist/fields/select/select-field.js +3 -2
  16. package/dist/fields/text/text-field.js +2 -1
  17. package/dist/fields/text-area/text-area-field.js +2 -1
  18. package/dist/forms/form-context.js +4 -1
  19. package/dist/forms/form-dom-scope.d.ts +34 -0
  20. package/dist/forms/form-dom-scope.js +20 -0
  21. package/dist/forms/form-layout.js +3 -1
  22. package/dist/forms/form-renderer.js +1 -0
  23. package/dist/presentation/tabs.js +2 -1
  24. package/dist/react.d.ts +1 -1
  25. package/package.json +7 -7
  26. package/src/fields/checkbox/checkbox-field.tsx +3 -1
  27. package/src/fields/code/code-field.tsx +2 -1
  28. package/src/fields/datetime/datetime-field.tsx +4 -2
  29. package/src/fields/direct-widget-dom-ids.test.tsx +142 -0
  30. package/src/fields/field-dom-ids.test.tsx +293 -0
  31. package/src/fields/field-renderer.tsx +2 -1
  32. package/src/fields/field-services-types.ts +25 -0
  33. package/src/fields/file/file-field.tsx +3 -2
  34. package/src/fields/group/group-field.tsx +3 -1
  35. package/src/fields/image/image-field.tsx +3 -2
  36. package/src/fields/numerical/numerical-field.tsx +2 -1
  37. package/src/fields/relation/relation-field.tsx +3 -2
  38. package/src/fields/relation/relation-many-field.tsx +3 -2
  39. package/src/fields/relation/relation-picker-create.test.tsx +203 -0
  40. package/src/fields/relation/relation-picker-refresh.test.tsx +325 -0
  41. package/src/fields/relation/relation-picker.module.css +32 -0
  42. package/src/fields/relation/relation-picker.tsx +59 -1
  43. package/src/fields/select/select-field.tsx +3 -2
  44. package/src/fields/text/text-field.tsx +2 -1
  45. package/src/fields/text-area/text-area-field.tsx +2 -1
  46. package/src/forms/form-context.tsx +2 -1
  47. package/src/forms/form-dom-scope.test.tsx +213 -0
  48. package/src/forms/form-dom-scope.tsx +70 -0
  49. package/src/forms/form-layout.tsx +7 -1
  50. package/src/forms/form-renderer.tsx +11 -0
  51. package/src/forms/form-submit-isolation.test.tsx +247 -0
  52. package/src/presentation/tab-dom-ids.test.tsx +153 -0
  53. package/src/presentation/tabs.test.tsx +49 -2
  54. package/src/presentation/tabs.tsx +11 -1
  55. package/src/react.ts +2 -0
@@ -13,6 +13,7 @@ import { Input, Label } from '@byline/ui/react'
13
13
  import cx from 'clsx'
14
14
 
15
15
  import { useFieldError, useFieldValue } from '../../forms/form-context'
16
+ import { useScopedDomId } from '../../forms/form-dom-scope'
16
17
  import { LocaleBadge } from '../locale-badge'
17
18
  import styles from './text-field.module.css'
18
19
 
@@ -41,7 +42,7 @@ export const TextField = ({
41
42
  const fieldError = useFieldError(fieldPath)
42
43
  const fieldValue = useFieldValue<string | undefined>(fieldPath)
43
44
  const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
44
- const htmlId = id ?? fieldPath
45
+ const htmlId = useScopedDomId(fieldPath, id)
45
46
 
46
47
  const handleChange = useCallback(
47
48
  (value: string) => {
@@ -13,6 +13,7 @@ import { Label, TextArea } from '@byline/ui/react'
13
13
  import cx from 'clsx'
14
14
 
15
15
  import { useFieldError, useFieldValue } from '../../forms/form-context'
16
+ import { useScopedDomId } from '../../forms/form-dom-scope'
16
17
  import { LocaleBadge } from '../locale-badge'
17
18
  import styles from './text-area-field.module.css'
18
19
 
@@ -41,7 +42,7 @@ export const TextAreaField = ({
41
42
  const fieldError = useFieldError(fieldPath)
42
43
  const fieldValue = useFieldValue<string | undefined>(fieldPath)
43
44
  const incomingValue = value ?? fieldValue ?? defaultValue ?? ''
44
- const htmlId = id ?? fieldPath
45
+ const htmlId = useScopedDomId(fieldPath, id)
45
46
 
46
47
  const handleChange = useCallback(
47
48
  (value: string) => {
@@ -15,6 +15,7 @@ import type { Field, FieldBeforeChangeResult, FieldHookContext } from '@byline/c
15
15
  import { normalizeHooks } from '@byline/core'
16
16
  import type { DocumentPatch, FieldSetPatch } from '@byline/core/patches'
17
17
 
18
+ import { FormDomScopeProvider } from './form-dom-scope'
18
19
  // Vendored nested get/set (see ./nested-path) — removes the lodash-es dep
19
20
  // outright. A bare `from 'lodash-es'` import otherwise pools into a single
20
21
  // ~85KB chunk that leaks onto the public frontend bundle (form-context is
@@ -749,7 +750,7 @@ export const FormProvider = ({
749
750
  subscribeSystemAvailableLocales: availableLocalesSlot.subscribe,
750
751
  }}
751
752
  >
752
- {children}
753
+ <FormDomScopeProvider>{children}</FormDomScopeProvider>
753
754
  </FormContext.Provider>
754
755
  )
755
756
  }
@@ -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
+ }
@@ -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'
@@ -75,6 +76,11 @@ export const FormLayout = ({
75
76
  const { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath } =
76
77
  useFormLayout(adminConfig, fields)
77
78
  const tabs = useFormTabs(fieldToTabPath, activeTabBySet)
79
+ // Two forms over one collection share its admin configuration, so their tab
80
+ // sets carry the same name and would otherwise derive the same trigger and
81
+ // panel ids. `aria-labelledby` resolves document-wide, so the second form's
82
+ // panel would name the first form's trigger.
83
+ const domScope = useFormDomScope()
78
84
 
79
85
  const renderField = (fieldName: string): ReactNode => {
80
86
  const field = fieldByName.get(fieldName)
@@ -117,7 +123,7 @@ export const FormLayout = ({
117
123
 
118
124
  const renderTabSet = (set: TabSetDefinition): ReactNode => {
119
125
  const { visibleTabs, activeTabName, activeTab, errorCounts } = tabs.resolve(set)
120
- const idBase = `tabset:${set.name}`
126
+ const idBase = `${domScope}tabset:${set.name}`
121
127
 
122
128
  return (
123
129
  <div key={idBase} className={cx('byline-form-tabset', styles.tabset)}>
@@ -422,6 +422,17 @@ const FormContent = ({
422
422
  }
423
423
 
424
424
  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
425
+ // A form rendered into a portal is not a DOM descendant of this one, so no
426
+ // invalid nested-form markup arises — but React propagates synthetic events
427
+ // along the *React* tree, so its submit still reaches this handler. Nothing
428
+ // about `currentTarget` reveals that: it is always this form while the
429
+ // handler runs, bubbled event or not. The event's origin is what
430
+ // distinguishes them.
431
+ //
432
+ // Deliberately no `preventDefault()` on the way out: the submit belongs to
433
+ // the inner form, and suppressing its default here would take that decision
434
+ // away from the handler that owns it.
435
+ if (e.target !== e.currentTarget) return
425
436
  e.preventDefault()
426
437
  if (mutationBlockedRef.current) return
427
438
  void submission.submit()
@@ -0,0 +1,247 @@
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 { defineAdminConfig } from '@byline/core'
12
+ import { adminTranslations } from '@byline/i18n/admin'
13
+ import { I18nProvider } from '@byline/i18n/react'
14
+ import { createPortal } from 'react-dom'
15
+ import { createRoot, type Root } from 'react-dom/client'
16
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
17
+
18
+ import { BylineFieldServicesProvider } from '../fields/field-services-context'
19
+ import { FormRenderer } from './form-renderer'
20
+
21
+ const globalWithAct = globalThis as any
22
+ globalWithAct.IS_REACT_ACT_ENVIRONMENT = true
23
+
24
+ defineAdminConfig({
25
+ i18n: {
26
+ admin: { defaultLocale: 'en', locales: ['en'] },
27
+ content: { defaultLocale: 'en', locales: ['en'] },
28
+ },
29
+ collections: [
30
+ {
31
+ path: 'pages',
32
+ labels: { singular: 'Page', plural: 'Pages' },
33
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
34
+ },
35
+ ],
36
+ slugifier: (value: string) => value.toLowerCase().trim().replace(/\s+/g, '-'),
37
+ })
38
+
39
+ const fieldServices = {
40
+ getCollectionDocuments: async () => ({ docs: [], total: 0 }),
41
+ uploadField: async () => ({}),
42
+ } as any
43
+
44
+ // Optional so submission is not blocked by validation: this file tests which
45
+ // form a submit event belongs to, not what the form does with it.
46
+ const fields = [{ name: 'title', label: 'Title', type: 'text' as const, optional: true }]
47
+
48
+ let container: HTMLDivElement
49
+ let root: Root
50
+ let portalHost: HTMLDivElement
51
+
52
+ beforeEach(() => {
53
+ container = document.createElement('div')
54
+ document.body.appendChild(container)
55
+ portalHost = document.createElement('div')
56
+ document.body.appendChild(portalHost)
57
+ root = createRoot(container)
58
+ })
59
+
60
+ afterEach(() => {
61
+ act(() => {
62
+ root.unmount()
63
+ })
64
+ container.remove()
65
+ portalHost.remove()
66
+ })
67
+
68
+ /**
69
+ * A nested form rendered through a portal: outside the parent `<form>` in the
70
+ * DOM, so no invalid nested-form markup arises, but still a descendant in the
71
+ * *React* tree — which is what React propagates synthetic events along.
72
+ */
73
+ const PortalledInnerForm = ({
74
+ stopPropagation,
75
+ onInnerSubmit,
76
+ }: {
77
+ stopPropagation: boolean
78
+ onInnerSubmit: () => void
79
+ }) =>
80
+ createPortal(
81
+ <form
82
+ data-testid="inner-form"
83
+ onSubmit={(event) => {
84
+ event.preventDefault()
85
+ if (stopPropagation) event.stopPropagation()
86
+ onInnerSubmit()
87
+ }}
88
+ >
89
+ <input name="innerTitle" defaultValue="" />
90
+ <button type="submit">Save inner</button>
91
+ </form>,
92
+ portalHost
93
+ )
94
+
95
+ const renderWithInnerForm = ({
96
+ parentSubmit,
97
+ onInnerSubmit = () => {},
98
+ stopPropagation = false,
99
+ }: {
100
+ parentSubmit: () => Promise<void>
101
+ onInnerSubmit?: () => void
102
+ stopPropagation?: boolean
103
+ }) => {
104
+ act(() => {
105
+ root.render(
106
+ <I18nProvider
107
+ bundle={adminTranslations({ locales: ['en'] })}
108
+ activeLocale="en"
109
+ defaultLocale="en"
110
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
111
+ >
112
+ <BylineFieldServicesProvider services={fieldServices}>
113
+ <FormRenderer
114
+ mode="create"
115
+ fields={fields}
116
+ collectionPath="pages"
117
+ onCancel={() => {}}
118
+ onSubmit={parentSubmit}
119
+ headerSlot={
120
+ <PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={onInnerSubmit} />
121
+ }
122
+ />
123
+ </BylineFieldServicesProvider>
124
+ </I18nProvider>
125
+ )
126
+ })
127
+ }
128
+
129
+ const parentForm = () => {
130
+ const form = container.querySelector('form')
131
+ if (form == null) throw new Error('parent form not found')
132
+ return form
133
+ }
134
+
135
+ const innerForm = () => {
136
+ const form = portalHost.querySelector<HTMLFormElement>('[data-testid="inner-form"]')
137
+ if (form == null) throw new Error('inner form not found')
138
+ return form
139
+ }
140
+
141
+ const submit = async (form: HTMLFormElement) => {
142
+ await act(async () => {
143
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
144
+ })
145
+ }
146
+
147
+ describe('submit isolation across a portal', () => {
148
+ /**
149
+ * The inner form here deliberately does **not** call `stopPropagation`, so
150
+ * these two cases exercise the parent's own guard. With the child-side defence
151
+ * in place as well, a broken parent guard would be invisible.
152
+ */
153
+ it('does not run the parent submit handler when a portalled inner form submits', async () => {
154
+ const parentSubmit = vi.fn(async () => {})
155
+ renderWithInnerForm({ parentSubmit })
156
+
157
+ await submit(innerForm())
158
+
159
+ expect(parentSubmit).not.toHaveBeenCalled()
160
+ })
161
+
162
+ it('still runs the inner form’s own handler', async () => {
163
+ const parentSubmit = vi.fn(async () => {})
164
+ const onInnerSubmit = vi.fn()
165
+ renderWithInnerForm({ parentSubmit, onInnerSubmit })
166
+
167
+ await submit(innerForm())
168
+
169
+ expect(onInnerSubmit).toHaveBeenCalledTimes(1)
170
+ expect(parentSubmit).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('still submits normally from the parent form itself', async () => {
174
+ const parentSubmit = vi.fn(async () => {})
175
+ renderWithInnerForm({ parentSubmit })
176
+
177
+ await submit(parentForm())
178
+
179
+ expect(parentSubmit).toHaveBeenCalledTimes(1)
180
+ })
181
+
182
+ it('leaves the parent form submitting on repeat after an inner submit', async () => {
183
+ const parentSubmit = vi.fn(async () => {})
184
+ renderWithInnerForm({ parentSubmit })
185
+
186
+ await submit(innerForm())
187
+ await submit(parentForm())
188
+ await submit(parentForm())
189
+
190
+ expect(parentSubmit).toHaveBeenCalledTimes(2)
191
+ })
192
+ })
193
+
194
+ /**
195
+ * The child-side defence, proved without the parent guard in the picture. If this
196
+ * were asserted against `FormRenderer`, a working parent guard would satisfy it
197
+ * whether or not the child stopped propagation, and the test would prove nothing
198
+ * about the child.
199
+ */
200
+ describe('the child-side stopPropagation defence', () => {
201
+ const PlainParent = ({
202
+ onParentSubmit,
203
+ stopPropagation,
204
+ }: {
205
+ onParentSubmit: () => void
206
+ stopPropagation: boolean
207
+ }) => (
208
+ <form
209
+ data-testid="plain-parent"
210
+ onSubmit={(event) => {
211
+ event.preventDefault()
212
+ onParentSubmit()
213
+ }}
214
+ >
215
+ <PortalledInnerForm stopPropagation={stopPropagation} onInnerSubmit={() => {}} />
216
+ </form>
217
+ )
218
+
219
+ const renderPlain = (props: { onParentSubmit: () => void; stopPropagation: boolean }) => {
220
+ act(() => {
221
+ root.render(<PlainParent {...props} />)
222
+ })
223
+ }
224
+
225
+ it('keeps an unguarded parent handler from running when the child stops propagation', async () => {
226
+ const onParentSubmit = vi.fn()
227
+ renderPlain({ onParentSubmit, stopPropagation: true })
228
+
229
+ await submit(innerForm())
230
+
231
+ expect(onParentSubmit).not.toHaveBeenCalled()
232
+ })
233
+
234
+ /**
235
+ * The control: the same unguarded parent, with the child defence removed. It
236
+ * fails in exactly the way the feature is meant to prevent, which is what makes
237
+ * the assertion above meaningful.
238
+ */
239
+ it('runs an unguarded parent handler when the child does not stop propagation', async () => {
240
+ const onParentSubmit = vi.fn()
241
+ renderPlain({ onParentSubmit, stopPropagation: false })
242
+
243
+ await submit(innerForm())
244
+
245
+ expect(onParentSubmit).toHaveBeenCalledTimes(1)
246
+ })
247
+ })