fix(livewire): prevent stale keys and refresh races

Use stable identities for dynamic rows and nested components so reordering does not target the wrong item. Scope storage count and volume-list refreshes, preserve PostgreSQL script identity when renaming files, use Livewire-aware destination redirects, and bump the version to 4.3.13.
This commit is contained in:
Andras Bacsai
2026-08-28 09:26:57 +02:00
parent 97831ef06e
commit b357522f94
30 changed files with 274 additions and 45 deletions
+15
View File
@@ -402,6 +402,21 @@ it('removes a domain', function () {
expect($this->application->fqdn)->toBe('https://www.example.com');
});
it('removes consecutive domains by stable row identity after indexes change', function () {
$this->application->update([
'fqdn' => 'https://first.example.com,https://second.example.com,https://third.example.com',
]);
$component = Livewire::test(Domains::class, ['application' => $this->application->fresh()]);
$component
->call('removeDomainByKey', hash('sha256', 'https://first.example.com|'))
->call('removeDomainByKey', hash('sha256', 'https://second.example.com|'))
->assertDispatched('success');
expect($this->application->fresh()->fqdn)->toBe('https://third.example.com');
});
it('does not revalidate dns on remaining domains when removing one', function () {
$settings = InstanceSettings::get();
$settings->is_dns_validation_enabled = true;
+35 -2
View File
@@ -135,13 +135,44 @@ test('livewire volume storage refreshes the storage list and configuration warni
->set('mount_path', '/app/data')
->call('submitPersistentVolume')
->assertDispatched('success')
->assertDispatched('refreshStorages')
->assertNotDispatched('refreshStorages')
->assertNotDispatched('refreshVolumeList')
->assertDispatched('configurationChanged')
->assertSet('activeTab', 'volumes')
->assertSet('volumeCount', 1)
->assertSee($this->application->uuid.'-data');
});
test('adding another volume refreshes only the mounted volume list', function () {
LocalPersistentVolume::create([
'name' => $this->application->uuid.'-first',
'mount_path' => '/app/first',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
Livewire::test(Storage::class, ['resource' => $this->application])
->set('name', 'second')
->set('mount_path', '/app/second')
->call('submitPersistentVolume')
->assertDispatched('refreshVolumeList')
->assertNotDispatched('refreshStorages');
});
test('deleting a volume asks only the parent storage component to refresh its counts', function () {
$volume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-data',
'mount_path' => '/app/data',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
Livewire::test(All::class, ['resource' => $this->application])
->call('delete', $volume->id, 'password')
->assertDispatched('storageCountsChanged')
->assertNotDispatched('refreshStorages');
});
test('volume storage list shows volumes added after it was mounted', function () {
$firstVolume = LocalPersistentVolume::create([
'name' => $this->application->uuid.'-first',
@@ -191,7 +222,9 @@ test('deleting a file mount refreshes the configuration warning', function () {
Livewire::test(FileStorage::class, ['fileStorage' => $file])
->call('delete', 'password')
->assertDispatched('configurationChanged');
->assertDispatched('configurationChanged')
->assertDispatched('storageCountsChanged')
->assertNotDispatched('refreshStorages');
expect($file->fresh())->toBeNull();
});
@@ -33,6 +33,16 @@ it('keeps storage backup schedule tables horizontally scrollable on mobile', fun
->and($css)->toMatch('/\.backup-table-grid\s*\{[^}]*min-width:\s*50rem;/');
});
it('keeps nested storage component keys stable when mounts are added or deleted', function () {
$view = file_get_contents(resource_path('views/livewire/project/service/storage.blade.php'));
expect($view)
->toContain('wire:key="volumes-{{ $resource->id }}"')
->toContain('wire:key="svc-volumes-{{ $resource->id }}"')
->not->toContain('wire:key="volumes-{{ $resource->id }}-{{ $this->volumeCount }}"')
->not->toContain('wire:key="svc-volumes-{{ $resource->id }}-{{ $this->volumeCount }}"');
});
use App\Livewire\Project\Service\VolumeBackup\Create as CreateServiceVolumeBackup;
use App\Livewire\Project\Shared\Storages\All;
use App\Models\Application;
+15
View File
@@ -127,6 +127,21 @@ it('groups configured domains and shows redirect settings in the table', functio
->and(substr_count($html, "id=\"service-domain-group-{$this->apiApp->id}\""))->toBe(1);
});
it('removes consecutive service domains by stable row identity after indexes change', function () {
$this->apiApp->update([
'fqdn' => 'https://first.example.com,https://second.example.com,https://third.example.com',
]);
$component = Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])]);
$component
->call('removeDomainByKey', hash('sha256', 'https://first.example.com|'.$this->apiApp->id))
->call('removeDomainByKey', hash('sha256', 'https://second.example.com|'.$this->apiApp->id))
->assertDispatched('success');
expect($this->apiApp->fresh()->fqdn)->toBe('https://third.example.com');
});
it('shows and persists the HTTP redirect control for HTTPS service applications', function () {
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSee('Redirect HTTP to HTTPS')
@@ -0,0 +1,64 @@
<?php
it('does not key nested Livewire components by mutable list positions', function () {
$postgresView = file_get_contents(resource_path('views/livewire/project/database/postgresql/general.blade.php'));
$proxyView = file_get_contents(resource_path('views/livewire/server/proxy/dynamic-configurations.blade.php'));
expect($postgresView)
->toContain(':wire:key="\'init-script-\'.md5($script[\'filename\'])"')
->not->toContain(':wire:key="$script[\'index\']"');
expect($proxyView)
->toContain('wire:key="proxy-navbar-{{ $fileName }}"')
->not->toContain('wire:key="{{ $fileName }}-{{ $loop->index }}"');
});
it('saves reindexed PostgreSQL scripts by their original stable identity', function () {
$editor = file_get_contents(app_path('Livewire/Project/Database/InitScript.php'));
$parent = file_get_contents(app_path('Livewire/Project/Database/Postgresql/General.php'));
expect($editor)
->toContain('public string $originalFilename;')
->toContain("dispatch('save_init_script', \$this->script, \$this->originalFilename)");
expect($parent)
->toContain('public function save_init_script($script, string $originalFilename)')
->toContain("firstWhere('filename', \$originalFilename)");
});
it('keeps editable and refreshed list row keys independent of their positions', function () {
$applicationDomains = file_get_contents(resource_path('views/livewire/project/application/partials/domain-row.blade.php'));
$serviceDomains = file_get_contents(resource_path('views/livewire/project/service/partials/domain-table.blade.php'));
$scheduledJobs = file_get_contents(resource_path('views/livewire/settings/scheduled-jobs.blade.php'));
expect($applicationDomains)
->not->toContain('wire:key="domain-row-{{ $index }}-')
->toContain('wire:key="domain-row-{{ md5(');
expect($serviceDomains)
->not->toContain('-{{ $index }}-')
->toContain('wire:key="svc-domain-{{ $row[\'service_application_id\'] ?? \'x\' }}-{{ md5(');
expect($scheduledJobs)
->not->toContain('wire:key="run-{{ $loop->index }}"')
->not->toContain('wire:key="skip-{{ $loop->index }}"')
->toContain('wire:key="run-{{ md5(serialize($run)) }}"')
->toContain('wire:key="skip-{{ md5(serialize($skip)) }}"');
});
it('keys nested Livewire status components rendered inside navigation loops', function () {
$sidebar = file_get_contents(resource_path('views/components/application/configuration-sidebar.blade.php'));
expect($sidebar)
->toContain(':key="\'application-server-status-\'.$application->uuid"');
});
it('keys rolling log lines by content occurrence instead of list position', function () {
$logs = file_get_contents(resource_path('views/livewire/project/shared/get-logs.blade.php'));
expect($logs)
->toContain('$lineOccurrences = [];')
->toContain('$lineFingerprint = md5($line);')
->toContain('wire:key="log-{{ $lineFingerprint }}-{{ $lineOccurrence }}"')
->not->toContain("'line-' . \$index");
});
@@ -275,6 +275,23 @@ describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
});
describe('Destination/Show team scope', function () {
test('deleting a destination redirects without rendering the deleted destination', function () {
$destination = SwarmDocker::create([
'uuid' => fake()->uuid(),
'name' => 'swarm-a-'.fake()->unique()->word(),
'network' => 'swarm-a-'.fake()->unique()->word(),
'server_id' => $this->serverA->id,
]);
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $destination->uuid])
->call('delete')
->assertRedirect(route('destination.index'));
expect($component->effects)
->toHaveKey('redirectUsingNavigate', true);
expect($destination->fresh())->toBeNull();
});
test('mount with other team destination UUID redirects to index', function () {
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
+2 -2
View File
@@ -23,8 +23,8 @@ it('publishes v4 branch builds under the commit sha with a traceable internal ve
->toContain('ARG COOLIFY_VERSION')
->toContain('ENV COOLIFY_VERSION=${COOLIFY_VERSION}')
->and($constants)
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.11'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.11')
->toContain("'version' => env('COOLIFY_VERSION') ?: '4.3.13'")
->and($versions['coolify']['v4']['version'])->toBe('4.3.13')
->and($versions['coolify']['nightly']['version'])->toBe('4.4-rc.1')
->and($nightlyVersions)->toBe($versions);
});