From 4d9dd73ddfb2a3088a3844bb974f2d149b302d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Gajda?= Date: Sat, 29 Aug 2026 21:05:49 +0200 Subject: [PATCH] Fix: forget a closed action's cached schema when it is unmounted (#20361) * uncache mounted schemas * Refine mounted action schema cache cleanup Amp-Thread-ID: https://ampcode.com/threads/T-01a04e2a-fe3c-731e-b615-9a4456fdceaa --------- Co-authored-by: Dan Harrin --- .../src/Concerns/InteractsWithActions.php | 23 ++++++++-- tests/src/Actions/ActionTest.php | 42 +++++++++++++++++++ tests/src/Fixtures/Pages/Actions.php | 28 +++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/actions/src/Concerns/InteractsWithActions.php b/packages/actions/src/Concerns/InteractsWithActions.php index adcb4947fc..f398b34a02 100644 --- a/packages/actions/src/Concerns/InteractsWithActions.php +++ b/packages/actions/src/Concerns/InteractsWithActions.php @@ -421,13 +421,22 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */ $this->mountedActions = []; $this->cachedMountedActions = null; - foreach ($this->cachedSchemas as $schemaName => $schema) { - if (str($schemaName)->startsWith('mountedActionSchema')) { + $this->forgetCachedMountedActionSchemas(); + + $this->mountAction($name, $arguments, $context); + } + + protected function forgetCachedMountedActionSchemas(int $fromNestingIndex = 0): void + { + foreach (array_keys($this->cachedSchemas) as $schemaName) { + if (! str_starts_with($schemaName, 'mountedActionSchema')) { + continue; + } + + if (((int) substr($schemaName, strlen('mountedActionSchema'))) >= $fromNestingIndex) { unset($this->cachedSchemas[$schemaName]); } } - - $this->mountAction($name, $arguments, $context); } public function cacheAction(Action $action): Action @@ -803,6 +812,12 @@ trait InteractsWithActions /** @phpstan-ignore trait.unused */ array_pop($this->cachedMountedActions); } + // The schemas of the actions that have just closed, which are cached by nesting index: an + // action mounted at one of those indexes later in this request would otherwise be handed + // the schema of the action that used to be there, since `getMountedActionSchema()` reads + // the cache before it builds anything. + $this->forgetCachedMountedActionSchemas(fromNestingIndex: count($this->mountedActions)); + if (! count($this->mountedActions)) { $action?->clearRecordAfter(); diff --git a/tests/src/Actions/ActionTest.php b/tests/src/Actions/ActionTest.php index 08136a22b4..9eb24cc87a 100644 --- a/tests/src/Actions/ActionTest.php +++ b/tests/src/Actions/ActionTest.php @@ -483,6 +483,48 @@ describe('nested actions', function (): void { }); }); +describe('unmounting actions', function (): void { + it('forgets a nested action schema before another action is mounted at the same index', function (): void { + $livewire = livewire(Actions::class) + ->mountAction('staleSchemaParent') + ->mountAction('first'); + + expect(array_keys($livewire->instance()->getSchema('mountedActionSchema1')->getFlatFields())) + ->toBe(['alpha']); + + $livewire->call('unmountThenMountAction', 'second'); + + expect(array_keys($livewire->instance()->getSchema('mountedActionSchema1')->getFlatFields())) + ->toBe(['beta']); + }); + + it('forgets a root action schema before another action is mounted at the same index', function (): void { + $livewire = livewire(Actions::class) + ->mountAction('staleSchemaParent'); + + expect(array_keys($livewire->instance()->getSchema('mountedActionSchema0')->getFlatFields())) + ->toBe(['parentField']); + + $livewire->call('unmountThenMountAction', 'staleSchemaOther'); + + expect(array_keys($livewire->instance()->getSchema('mountedActionSchema0')->getFlatFields())) + ->toBe(['otherField']); + }); + + it('preserves the schema of an action that remains mounted', function (): void { + $livewire = livewire(Actions::class) + ->mountAction('staleSchemaParent') + ->mountAction('first'); + + $livewire->call('unmountAction'); + + expect(array_keys($livewire->instance()->getSchema('mountedActionSchema0')->getFlatFields())) + ->toBe(['parentField']) + ->and($livewire->instance()->getSchema('mountedActionSchema1')) + ->toBeNull(); + }); +}); + describe('extra modal footer actions', function (): void { it('can mount an action that has a group in `extraModalFooterActions()`', function (): void { livewire(Actions::class) diff --git a/tests/src/Fixtures/Pages/Actions.php b/tests/src/Fixtures/Pages/Actions.php index a7a3fc9607..6ca0d84d39 100644 --- a/tests/src/Fixtures/Pages/Actions.php +++ b/tests/src/Fixtures/Pages/Actions.php @@ -121,6 +121,28 @@ class Actions extends Page ]) ->action(fn () => null), ]), + Action::make('staleSchemaParent') + ->schema([ + TextInput::make('parentField'), + ]) + ->registerModalActions([ + Action::make('first') + ->schema([ + TextInput::make('alpha'), + ]) + ->action(fn () => null), + Action::make('second') + ->schema([ + TextInput::make('beta'), + ]) + ->action(fn () => null), + ]) + ->action(fn () => null), + Action::make('staleSchemaOther') + ->schema([ + TextInput::make('otherField'), + ]) + ->action(fn () => null), Action::make('grandparentWithModalCloseCancellation') ->schema([ TextInput::make('grandparentValue') @@ -281,4 +303,10 @@ class Actions extends Page }), ]; } + + public function unmountThenMountAction(string $name): void + { + $this->unmountAction(); + $this->mountAction($name); + } }