diff --git a/packages/react/src/Form.ts b/packages/react/src/Form.ts index abcadcffa..28bcd78a9 100644 --- a/packages/react/src/Form.ts +++ b/packages/react/src/Form.ts @@ -14,10 +14,12 @@ import { import { NamedInputEvent, ValidationConfig } from 'laravel-precognition' import { isEqual } from 'lodash-es' import React, { + createContext, createElement, FormEvent, forwardRef, ReactNode, + useContext, useEffect, useImperativeHandle, useMemo, @@ -41,6 +43,8 @@ type FormSubmitOptions = Omit undefined +export const FormContext = createContext(undefined) + const Form = forwardRef( ( { @@ -225,7 +229,7 @@ const Form = forwardRef( setIsDirty(false) } - const exposed = () => ({ + const exposed = { errors: form.errors, hasErrors: form.hasErrors, processing: form.processing, @@ -251,11 +255,11 @@ const Form = forwardRef( form.validate(...UseFormUtils.mergeHeadersForValidation(field, config, headers)), touch: form.touch, touched: form.touched, - }) + } - useImperativeHandle(ref, exposed, [form, isDirty, submit]) + useImperativeHandle(ref, () => exposed, [form, isDirty, submit]) - return createElement( + const formNode = createElement( 'form', { ...props, @@ -272,11 +276,17 @@ const Form = forwardRef( // See: https://github.com/inertiajs/inertia/pull/2536 inert: disableWhileProcessing && form.processing && 'true', }, - typeof children === 'function' ? children(exposed()) : children, + typeof children === 'function' ? children(exposed) : children, ) + + return createElement(FormContext.Provider, { value: exposed }, formNode) }, ) Form.displayName = 'InertiaForm' +export function useFormContext(): FormComponentRef | undefined { + return useContext(FormContext) +} + export default Form diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 442e6ece1..a04c78dbc 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,7 +6,7 @@ export const router = Router export { default as App } from './App' export { default as createInertiaApp } from './createInertiaApp' export { default as Deferred } from './Deferred' -export { default as Form } from './Form' +export { default as Form, useFormContext } from './Form' export { default as Head } from './Head' export { default as InfiniteScroll } from './InfiniteScroll' export { InertiaLinkProps, default as Link } from './Link' diff --git a/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx new file mode 100644 index 000000000..3c3ef5f6b --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/ChildComponent.tsx @@ -0,0 +1,42 @@ +import { useFormContext } from '@inertiajs/react' + +export default ({ formId }: { formId?: string }) => { + const form = useFormContext() + + return ( + <> + {form ? ( +
+ Child: Form is {form.isDirty ? 'dirty' : 'clean'} + {form.hasErrors && | Child: Form has errors} + {form.errors.name && | Error: {form.errors.name}} +
+ ) : ( +
No form context available
+ )} + + + + {!formId && ( + <> + + + + + )} + + ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx b/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx new file mode 100644 index 000000000..e2309b0dc --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.tsx @@ -0,0 +1,13 @@ +import { useFormContext } from '@inertiajs/react' + +export default () => { + const form = useFormContext() + + return form ? ( +
+ Deeply Nested: Form is {form.isDirty ? 'dirty' : 'clean'} +
+ ) : ( +
No context
+ ) +} diff --git a/packages/react/test-app/Pages/FormComponent/Context/Default.tsx b/packages/react/test-app/Pages/FormComponent/Context/Default.tsx new file mode 100644 index 000000000..606ab7573 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/Default.tsx @@ -0,0 +1,28 @@ +import { Form } from '@inertiajs/react' +import ChildComponent from './ChildComponent' +import NestedComponent from './NestedComponent' +import OutsideFormComponent from './OutsideFormComponent' + +export default () => ( + <> +
+ {({ isDirty, hasErrors, errors }) => ( + <> +
+ Parent: Form is {isDirty ? 'dirty' : 'clean'} + {hasErrors && | Parent: Form has errors} + {errors.name && | {errors.name}} +
+ + + + + + + + )} + + + + +) diff --git a/packages/react/test-app/Pages/FormComponent/Context/Methods.tsx b/packages/react/test-app/Pages/FormComponent/Context/Methods.tsx new file mode 100644 index 000000000..e4fe5b8a6 --- /dev/null +++ b/packages/react/test-app/Pages/FormComponent/Context/Methods.tsx @@ -0,0 +1,22 @@ +import { Form } from '@inertiajs/react' +import MethodsTestComponent from './MethodsTestComponent' + +export default () => ( +
+ {({ isDirty, hasErrors, errors }) => ( + <> +
+ {String(isDirty)} + {String(hasErrors)} + {hasErrors &&
{JSON.stringify(errors, null, 2)}
} +
+ + + + + + + diff --git a/packages/svelte/test-app/Pages/FormComponent/Context/MethodsTestComponent.svelte b/packages/svelte/test-app/Pages/FormComponent/Context/MethodsTestComponent.svelte new file mode 100644 index 000000000..06503d538 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Context/MethodsTestComponent.svelte @@ -0,0 +1,65 @@ + + +{#if $form} + {$form.isDirty} + {$form.hasErrors} + {$form.processing} + {$form.wasSuccessful} + {$form.recentlySuccessful} + {#if $form.hasErrors}
{JSON.stringify($form.errors, null, 2)}
{/if} + + + + + + + + + + + + + + + + + + + {#if getDataResult} +
{getDataResult}
+ {/if} + {#if getFormDataResult} +
{getFormDataResult}
+ {/if} +{:else} +
No form context available
+{/if} diff --git a/packages/svelte/test-app/Pages/FormComponent/Context/Multiple.svelte b/packages/svelte/test-app/Pages/FormComponent/Context/Multiple.svelte new file mode 100644 index 000000000..90b877fff --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Context/Multiple.svelte @@ -0,0 +1,22 @@ + + +
+
+ Form 1 Parent: {isDirty ? 'dirty' : 'clean'} + {#if errors.name} | Error: {errors.name}{/if} +
+ + + + +
+
+ Form 2 Parent: {isDirty ? 'dirty' : 'clean'} + {#if errors.name} | Error: {errors.name}{/if} +
+ + + diff --git a/packages/svelte/test-app/Pages/FormComponent/Context/NestedComponent.svelte b/packages/svelte/test-app/Pages/FormComponent/Context/NestedComponent.svelte new file mode 100644 index 000000000..74f6f02fd --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Context/NestedComponent.svelte @@ -0,0 +1,5 @@ + + + diff --git a/packages/svelte/test-app/Pages/FormComponent/Context/OutsideFormComponent.svelte b/packages/svelte/test-app/Pages/FormComponent/Context/OutsideFormComponent.svelte new file mode 100644 index 000000000..1c38b5da4 --- /dev/null +++ b/packages/svelte/test-app/Pages/FormComponent/Context/OutsideFormComponent.svelte @@ -0,0 +1,11 @@ + + +{#if $form === undefined} +
Correctly returns undefined when used outside a Form component
+{:else} +
Unexpectedly has form context
+{/if} diff --git a/packages/vue3/src/form.ts b/packages/vue3/src/form.ts index 72a309892..ab109ee7d 100644 --- a/packages/vue3/src/form.ts +++ b/packages/vue3/src/form.ts @@ -14,13 +14,28 @@ import { } from '@inertiajs/core' import { NamedInputEvent, ValidationConfig } from 'laravel-precognition' import { isEqual } from 'lodash-es' -import { computed, defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, SlotsType, watch } from 'vue' +import { + computed, + defineComponent, + h, + inject, + InjectionKey, + onBeforeUnmount, + onMounted, + PropType, + provide, + ref, + SlotsType, + watch, +} from 'vue' import useForm from './useForm' type FormSubmitOptions = Omit const noop = () => undefined +export const FormContextKey: InjectionKey = Symbol('InertiaFormContext') + const Form = defineComponent({ name: 'Form', slots: Object as SlotsType<{ @@ -320,6 +335,8 @@ const Form = defineComponent({ expose(exposed) + provide(FormContextKey, exposed) + return () => { return h( 'form', @@ -340,4 +357,8 @@ const Form = defineComponent({ }, }) +export function useFormContext(): FormComponentRef | undefined { + return inject(FormContextKey) +} + export default Form diff --git a/packages/vue3/src/index.ts b/packages/vue3/src/index.ts index ad239eabc..875db6a1f 100755 --- a/packages/vue3/src/index.ts +++ b/packages/vue3/src/index.ts @@ -5,7 +5,7 @@ export { progress, router } from '@inertiajs/core' export { default as App, usePage } from './app' export { default as createInertiaApp } from './createInertiaApp' export { default as Deferred } from './deferred' -export { default as Form } from './form' +export { default as Form, useFormContext } from './form' export { default as Head } from './head' export { default as InfiniteScroll } from './infiniteScroll' export { InertiaLinkProps, default as Link } from './link' diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/ChildComponent.vue b/packages/vue3/test-app/Pages/FormComponent/Context/ChildComponent.vue new file mode 100644 index 000000000..e5c64d72f --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/ChildComponent.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.vue b/packages/vue3/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.vue new file mode 100644 index 000000000..fbd1fa90a --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/DeeplyNestedComponent.vue @@ -0,0 +1,12 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/Default.vue b/packages/vue3/test-app/Pages/FormComponent/Context/Default.vue new file mode 100644 index 000000000..2a3e53773 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/Default.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/Methods.vue b/packages/vue3/test-app/Pages/FormComponent/Context/Methods.vue new file mode 100644 index 000000000..484c299d6 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/Methods.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/MethodsTestComponent.vue b/packages/vue3/test-app/Pages/FormComponent/Context/MethodsTestComponent.vue new file mode 100644 index 000000000..4c11bbdf9 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/MethodsTestComponent.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/Multiple.vue b/packages/vue3/test-app/Pages/FormComponent/Context/Multiple.vue new file mode 100644 index 000000000..918404a97 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/Multiple.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/NestedComponent.vue b/packages/vue3/test-app/Pages/FormComponent/Context/NestedComponent.vue new file mode 100644 index 000000000..815f9ba98 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/NestedComponent.vue @@ -0,0 +1,7 @@ + + + diff --git a/packages/vue3/test-app/Pages/FormComponent/Context/OutsideFormComponent.vue b/packages/vue3/test-app/Pages/FormComponent/Context/OutsideFormComponent.vue new file mode 100644 index 000000000..0ad2c2b08 --- /dev/null +++ b/packages/vue3/test-app/Pages/FormComponent/Context/OutsideFormComponent.vue @@ -0,0 +1,10 @@ + + + diff --git a/tests/form-component-context.spec.ts b/tests/form-component-context.spec.ts new file mode 100644 index 000000000..c9f52dfdd --- /dev/null +++ b/tests/form-component-context.spec.ts @@ -0,0 +1,333 @@ +import test, { expect } from '@playwright/test' +import { pageLoads } from './support' + +test.describe('Form Component Context', () => { + test.describe('Basic Context', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/context/default') + }) + + test('provides context to child components', async ({ page }) => { + await expect(page.getByText('Child: Form is clean')).toBeVisible() + }) + + test('provides context to deeply nested components', async ({ page }) => { + await expect(page.getByText('Deeply Nested: Form is clean')).toBeVisible() + }) + + test('returns undefined outside Form component', async ({ page }) => { + await expect(page.getByText('Correctly returns undefined when used outside a Form component')).toBeVisible() + }) + + test('syncs isDirty state between parent and child', async ({ page }) => { + // Initial state - clean + await expect(page.getByText('Parent: Form is clean')).toBeVisible() + await expect(page.getByText('Child: Form is clean')).toBeVisible() + + // Make form dirty + await page.locator('input[name="name"]').fill('Jane Doe') + + // Both parent and child should show dirty + await expect(page.getByText('Parent: Form is dirty')).toBeVisible() + await expect(page.getByText('Child: Form is dirty')).toBeVisible() + }) + + test('can submit form from child component using context', async ({ page }) => { + await page.locator('input[name="name"]').fill('Test Name') + await page.locator('input[name="email"]').fill('test@example.com') + + // Submit from child component + await page.getByRole('button', { name: 'Submit from Child' }).click() + + // Should navigate to dump page + await page.waitForURL('/dump/post') + }) + + test('can reset form from child component using context', async ({ page }) => { + // Change the default values + await page.locator('input[name="name"]').fill('Changed Name') + await page.locator('input[name="email"]').fill('changed@example.com') + + // Verify form is dirty + await expect(page.getByText('Child: Form is dirty')).toBeVisible() + + // Reset from child + await page.getByRole('button', { name: 'Reset from Child' }).click() + + // Should be reset to defaults + await expect(page.locator('input[name="name"]')).toHaveValue('John Doe') + await expect(page.locator('input[name="email"]')).toHaveValue('john@example.com') + await expect(page.getByText('Child: Form is clean')).toBeVisible() + }) + + test('can set errors from child component using context', async ({ page }) => { + // Set error from child + await page.getByRole('button', { name: 'Set Error' }).click() + + // Both parent and child should show the error + await expect(page.getByText('Error set from child component')).toHaveCount(2) + await expect(page.getByText('Parent: Form has errors')).toBeVisible() + await expect(page.getByText('Child: Form has errors')).toBeVisible() + }) + + test('can clear errors from child component using context', async ({ page }) => { + // First set an error + await page.getByRole('button', { name: 'Set Error' }).click() + await expect(page.getByText('Error set from child component').first()).toBeVisible() + + // Clear errors from child + await page.getByRole('button', { name: 'Clear Error' }).click() + + // Errors should be cleared + await expect(page.getByText('Error set from child component')).not.toBeVisible() + }) + + test('can set defaults from child component using context', async ({ page }) => { + // Change values + await page.locator('input[name="name"]').fill('New Default Name') + await page.locator('input[name="email"]').fill('newdefault@example.com') + + // Form should be dirty + await expect(page.getByText('Child: Form is dirty')).toBeVisible() + + // Set new defaults from child + await page.getByRole('button', { name: 'Set Defaults' }).click() + + // Form should now be clean (because current values are now the defaults) + await expect(page.getByText('Child: Form is clean')).toBeVisible() + + // Reset should now go back to the new defaults + await page.locator('input[name="name"]').fill('Another Name') + await expect(page.getByText('Child: Form is dirty')).toBeVisible() + + await page.getByRole('button', { name: 'Reset from Child' }).click() + await expect(page.locator('input[name="name"]')).toHaveValue('New Default Name') + await expect(page.locator('input[name="email"]')).toHaveValue('newdefault@example.com') + }) + }) + + test.describe('Context Methods', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/context/methods') + }) + + test('child can access all state properties through context', async ({ page }) => { + // Check initial state values are all false + const booleanStates = page.locator('span').filter({ hasText: /^(true|false)$/ }) + await expect(booleanStates.first()).toHaveText('false') // isDirty + await expect(booleanStates.nth(1)).toHaveText('false') // hasErrors + await expect(booleanStates.nth(2)).toHaveText('false') // processing + await expect(booleanStates.nth(3)).toHaveText('false') // wasSuccessful + await expect(booleanStates.nth(4)).toHaveText('false') // recentlySuccessful + }) + + test('can submit from child using context', async ({ page }) => { + await page.locator('input[name="name"]').fill('Test') + await page.locator('input[name="email"]').fill('test@example.com') + + await page.getByRole('button', { name: 'submit()' }).click() + + await page.waitForURL('/dump/post') + }) + + test('can reset all fields from child', async ({ page }) => { + // Change all fields + await page.locator('input[name="name"]').fill('Changed') + await page.locator('input[name="email"]').fill('changed@example.com') + await page.locator('textarea[name="bio"]').fill('Changed bio') + + // Reset all + await page.getByRole('button', { name: 'reset()', exact: true }).click() + + // Should be back to defaults + await expect(page.locator('input[name="name"]')).toHaveValue('Initial Name') + await expect(page.locator('input[name="email"]')).toHaveValue('initial@example.com') + await expect(page.locator('textarea[name="bio"]')).toHaveValue('Initial bio') + }) + + test('can reset specific field from child', async ({ page }) => { + // Change fields + await page.locator('input[name="name"]').fill('Changed Name') + await page.locator('input[name="email"]').fill('changed@example.com') + + // Reset only name + await page.getByRole('button', { name: "reset('name')", exact: true }).click() + + // Name should be reset, email should stay changed + await expect(page.locator('input[name="name"]')).toHaveValue('Initial Name') + await expect(page.locator('input[name="email"]')).toHaveValue('changed@example.com') + }) + + test('can reset multiple specific fields from child', async ({ page }) => { + // Change fields + await page.locator('input[name="name"]').fill('Changed Name') + await page.locator('input[name="email"]').fill('changed@example.com') + await page.locator('textarea[name="bio"]').fill('Changed bio') + + // Reset name and email + await page.getByRole('button', { name: "reset('name', 'email')" }).click() + + // Name and email should be reset, bio should stay changed + await expect(page.locator('input[name="name"]')).toHaveValue('Initial Name') + await expect(page.locator('input[name="email"]')).toHaveValue('initial@example.com') + await expect(page.locator('textarea[name="bio"]')).toHaveValue('Changed bio') + }) + + test('can set single error from child', async ({ page }) => { + await page.getByRole('button', { name: "setError('name')" }).click() + + // Check error appears (shown in both parent and child, so use first()) + await expect(page.getByText('Name is invalid').first()).toBeVisible() + }) + + test('can set multiple errors from child', async ({ page }) => { + await page.getByRole('button', { name: 'setError({...})' }).click() + + // Check all errors appear (shown in both parent and child, so use first()) + await expect(page.getByText('Name error from child').first()).toBeVisible() + await expect(page.getByText('Email error from child').first()).toBeVisible() + await expect(page.getByText('Bio error from child').first()).toBeVisible() + }) + + test('can clear all errors from child', async ({ page }) => { + // Set errors first + await page.getByRole('button', { name: 'setError({...})' }).click() + await expect(page.getByText('Name error from child').first()).toBeVisible() + + // Clear all errors + await page.getByRole('button', { name: 'clearErrors()', exact: true }).click() + + await expect(page.getByText('Name error from child')).not.toBeVisible() + await expect(page.getByText('Email error from child')).not.toBeVisible() + await expect(page.getByText('Bio error from child')).not.toBeVisible() + }) + + test('can clear specific error from child', async ({ page }) => { + // Set multiple errors + await page.getByRole('button', { name: 'setError({...})' }).click() + + // Clear only name error + await page.getByRole('button', { name: "clearErrors('name')", exact: true }).click() + + // Name error should be cleared, others should remain + await expect(page.getByText('Name error from child')).not.toBeVisible() + await expect(page.getByText('Email error from child').first()).toBeVisible() + await expect(page.getByText('Bio error from child').first()).toBeVisible() + }) + + test('can reset and clear errors together from child', async ({ page }) => { + // Change values and set errors + await page.locator('input[name="name"]').fill('Changed') + await page.getByRole('button', { name: "setError('name')" }).click() + await expect(page.getByText('Name is invalid').first()).toBeVisible() + + // Reset and clear errors + await page.getByRole('button', { name: 'resetAndClearErrors()', exact: true }).click() + + // Both should be cleared + await expect(page.getByText('Name is invalid')).not.toBeVisible() + await expect(page.locator('input[name="name"]')).toHaveValue('Initial Name') + }) + + test('can reset specific field and clear its error from child', async ({ page }) => { + // Set errors and change values + await page.locator('input[name="name"]').fill('Changed') + await page.locator('input[name="email"]').fill('changed@example.com') + await page.getByRole('button', { name: 'setError({...})' }).click() + + // Reset and clear only name + await page.getByRole('button', { name: "resetAndClearErrors('name')" }).click() + + // Name should be reset and its error cleared + await expect(page.locator('input[name="name"]')).toHaveValue('Initial Name') + await expect(page.getByText('Name error from child')).not.toBeVisible() + + // Email should still be changed and have error + await expect(page.locator('input[name="email"]')).toHaveValue('changed@example.com') + await expect(page.getByText('Email error from child').first()).toBeVisible() + }) + + test('can set defaults from child', async ({ page }) => { + // Change values + await page.locator('input[name="name"]').fill('New Default') + + // Set defaults + await page.getByRole('button', { name: 'defaults()' }).click() + + // Should now be clean (current values are now defaults) + await page.locator('input[name="name"]').fill('Something else') + await page.getByRole('button', { name: 'reset()', exact: true }).click() + await expect(page.locator('input[name="name"]')).toHaveValue('New Default') + }) + + test('can get data as object from child', async ({ page }) => { + await page.locator('input[name="name"]').fill('Test Name') + await page.locator('input[name="email"]').fill('test@example.com') + await page.locator('textarea[name="bio"]').fill('Test bio') + + await page.getByRole('button', { name: 'getData()' }).click() + + // Check the result is displayed + await expect(page.getByText('Test Name')).toBeVisible() + await expect(page.getByText('test@example.com')).toBeVisible() + await expect(page.getByText('Test bio')).toBeVisible() + }) + + test('can get FormData from child', async ({ page }) => { + await page.locator('input[name="name"]').fill('Test Name') + await page.locator('input[name="email"]').fill('test@example.com') + + await page.getByRole('button', { name: 'getFormData()' }).click() + + // Check the result is displayed + await expect(page.getByText('Test Name')).toBeVisible() + await expect(page.getByText('test@example.com')).toBeVisible() + }) + }) + + test.describe('Multiple Forms', () => { + test.beforeEach(async ({ page }) => { + pageLoads.watch(page) + await page.goto('/form-component/context/multiple') + }) + + test('each form provides isolated context to its children', async ({ page }) => { + // Both forms should start clean + await expect(page.getByText('Child: Form is clean').first()).toBeVisible() + await expect(page.getByText('Child: Form is clean').nth(1)).toBeVisible() + + // Make form 1 dirty + await page.locator('input[name="name"]').first().fill('Changed') + + // Only form 1 should be dirty + await expect(page.getByText('Form 1 Parent: dirty')).toBeVisible() + await expect(page.getByText('Child: Form is dirty')).toBeVisible() + await expect(page.getByText('Form 2 Parent: clean')).toBeVisible() + await expect(page.getByText('Child: Form is clean')).toBeVisible() + }) + + test('setting error in one form does not affect the other', async ({ page }) => { + // Set error in form 1 via child + await page.getByRole('button', { name: 'Set Error' }).first().click() + + // Only form 1 should have error + await expect(page.getByText('Error: Error from child').first()).toBeVisible() + await expect(page.getByText('Error: Error from child')).toHaveCount(2) // Parent + Child of form 1 + await expect(page.getByText('Form 2 Parent: clean')).toBeVisible() + + // Set error in form 2 via child + await page.getByRole('button', { name: 'Set Error' }).nth(1).click() + + // Both should have errors now (4 total: 2 per form) + await expect(page.getByText('Error: Error from child')).toHaveCount(4) + + // Clear error in form 1 + await page.getByRole('button', { name: 'Clear Error' }).first().click() + + // Only form 2 should have errors now + await expect(page.getByText('Error: Error from child')).toHaveCount(2) + }) + }) +})