diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
index 67c7e3e..d580d33 100644
--- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
+++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php
@@ -24,8 +24,6 @@ class BackupItemsRelationManager extends RelationManager
{
protected static string $relationship = 'items';
- public ?int $pollUntil = null;
-
protected $listeners = [
'backup-set-policy-picker:close' => 'closeAddPoliciesModal',
];
@@ -33,21 +31,12 @@ class BackupItemsRelationManager extends RelationManager
public function closeAddPoliciesModal(): void
{
$this->unmountAction();
- $this->resetTable();
-
- $this->pollUntil = now()->addSeconds(20)->getTimestamp();
- }
-
- public function shouldPollTable(): bool
- {
- return $this->pollUntil !== null && now()->getTimestamp() < $this->pollUntil;
}
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
- ->poll(fn (): ?string => (filled($this->mountedActions) || ! $this->shouldPollTable()) ? null : '2s')
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Item')
@@ -120,6 +109,12 @@ public function table(Table $table): Table
])
->filters([])
->headerActions([
+ Actions\Action::make('refreshTable')
+ ->label('Refresh')
+ ->icon('heroicon-o-arrow-path')
+ ->action(function (): void {
+ $this->resetTable();
+ }),
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
@@ -218,10 +213,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
-
- $this->resetTable();
-
- $this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
@@ -312,10 +303,6 @@ public function table(Table $table): Table
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
-
- $this->resetTable();
-
- $this->pollUntil = now()->addSeconds(20)->getTimestamp();
}),
]),
]);
diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php
index bece723..4553c11 100644
--- a/app/Livewire/BackupSetPolicyPickerTable.php
+++ b/app/Livewire/BackupSetPolicyPickerTable.php
@@ -325,8 +325,6 @@ public function table(Table $table): Table
])
->send();
- $this->resetTable();
-
$this->dispatch('backup-set-policy-picker:close')
->to(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class);
}),
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 9d90f61..76385ed 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -38,6 +38,10 @@ public function panel(Panel $panel): Panel
->colors([
'primary' => Color::Amber,
])
+ ->renderHook(
+ PanelsRenderHook::HEAD_END,
+ fn () => view('filament.partials.livewire-intercept-shim')->render()
+ )
->renderHook(
PanelsRenderHook::BODY_END,
fn () => (bool) config('tenantpilot.bulk_operations.progress_widget_enabled', true)
diff --git a/public/js/tenantpilot/livewire-intercept-shim.js b/public/js/tenantpilot/livewire-intercept-shim.js
new file mode 100644
index 0000000..8bac711
--- /dev/null
+++ b/public/js/tenantpilot/livewire-intercept-shim.js
@@ -0,0 +1,71 @@
+(() => {
+ // TenantPilot shim: ensure Livewire interceptMessage callbacks run in a safe order.
+ // This prevents Filament's notifications asset (and others) from calling an undefined
+ // animation callback when onSuccess fires before onFinish.
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const Livewire = window.Livewire;
+
+ if (!Livewire || typeof Livewire.interceptMessage !== 'function') {
+ return;
+ }
+
+ if (Livewire.__tenantpilotInterceptMessageShimApplied) {
+ return;
+ }
+
+ const original = Livewire.interceptMessage.bind(Livewire);
+
+ Livewire.interceptMessage = (handler) => {
+ if (typeof handler !== 'function') {
+ return original(handler);
+ }
+
+ return original((context) => {
+ if (!context || typeof context !== 'object') {
+ return handler(context);
+ }
+
+ const originalOnFinish = context.onFinish;
+ const originalOnSuccess = context.onSuccess;
+
+ if (typeof originalOnFinish !== 'function' || typeof originalOnSuccess !== 'function') {
+ return handler(context);
+ }
+
+ const finishCallbacks = [];
+
+ const onFinish = (callback) => {
+ if (typeof callback === 'function') {
+ finishCallbacks.push(callback);
+ }
+
+ return originalOnFinish(callback);
+ };
+
+ const onSuccess = (callback) => {
+ return originalOnSuccess((...args) => {
+ // Ensure any registered finish callbacks are run before success callbacks.
+ // We don't swallow errors; we just stabilize ordering.
+ for (const finishCallback of finishCallbacks) {
+ finishCallback(...args);
+ }
+
+ if (typeof callback === 'function') {
+ return callback(...args);
+ }
+ });
+ };
+
+ return handler({
+ ...context,
+ onFinish,
+ onSuccess,
+ });
+ });
+ };
+
+ Livewire.__tenantpilotInterceptMessageShimApplied = true;
+})();
diff --git a/resources/views/filament/modals/backup-set-policy-picker.blade.php b/resources/views/filament/modals/backup-set-policy-picker.blade.php
index 8d5502f..b068b97 100644
--- a/resources/views/filament/modals/backup-set-policy-picker.blade.php
+++ b/resources/views/filament/modals/backup-set-policy-picker.blade.php
@@ -1,3 +1,5 @@
-
+
diff --git a/resources/views/filament/partials/livewire-intercept-shim.blade.php b/resources/views/filament/partials/livewire-intercept-shim.blade.php
new file mode 100644
index 0000000..52372ef
--- /dev/null
+++ b/resources/views/filament/partials/livewire-intercept-shim.blade.php
@@ -0,0 +1 @@
+
diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php
index 2f8fc4b..87c0a21 100644
--- a/resources/views/livewire/bulk-operation-progress.blade.php
+++ b/resources/views/livewire/bulk-operation-progress.blade.php
@@ -58,6 +58,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
timer: null,
activeSinceMs: null,
fastUntilMs: null,
+ teardownObserver: null,
init() {
this.onVisibilityChange = this.onVisibilityChange.bind(this);
@@ -66,6 +67,16 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.onNavigated = this.onNavigated.bind(this);
window.addEventListener('livewire:navigated', this.onNavigated);
+ // Ensure we always detach listeners when Livewire/Filament removes this
+ // element (for example during modal unmounts or navigation/morph).
+ this.teardownObserver = new MutationObserver(() => {
+ if (!this.$el || this.$el.isConnected !== true) {
+ this.destroy();
+ }
+ });
+
+ this.teardownObserver.observe(document.body, { childList: true, subtree: true });
+
// First sync immediately.
this.schedule(0);
},
@@ -74,6 +85,11 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
this.stop();
window.removeEventListener('visibilitychange', this.onVisibilityChange);
window.removeEventListener('livewire:navigated', this.onNavigated);
+
+ if (this.teardownObserver) {
+ this.teardownObserver.disconnect();
+ this.teardownObserver = null;
+ }
},
stop() {
@@ -164,7 +180,25 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
return;
}
- await this.$wire.refreshRuns();
+ try {
+ await this.$wire.refreshRuns();
+ } catch (error) {
+ // Livewire will reject pending action promises when requests are
+ // cancelled (for example during `wire:navigate`). That's expected
+ // for this background poller, so we swallow cancellation rejections.
+ const isCancellation = Boolean(
+ error &&
+ typeof error === 'object' &&
+ error.status === null &&
+ error.body === null &&
+ error.json === null &&
+ error.errors === null,
+ );
+
+ if (!isCancellation) {
+ console.warn('Ops UX widget refreshRuns failed', error);
+ }
+ }
const next = this.nextIntervalMs();
if (next === null) {
@@ -181,7 +215,7 @@ class="block rounded-lg bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-center te
const delay = Math.max(0, Number(delayMs ?? 0));
this.timer = setTimeout(() => {
- this.tick();
+ this.tick().catch(() => {});
}, delay);
},
};
diff --git a/specs/057-filament-v5-upgrade/tasks.md b/specs/057-filament-v5-upgrade/tasks.md
index 0359301..7f3d98c 100644
--- a/specs/057-filament-v5-upgrade/tasks.md
+++ b/specs/057-filament-v5-upgrade/tasks.md
@@ -109,6 +109,15 @@ ## Phase 6: Polish & Cross-Cutting Concerns
- [X] T031 Run full test suite gate via php artisan test (phpunit.xml)
- [X] T032 [P] Update specs/057-filament-v5-upgrade/research.md with final dependency decisions and any deviations
+### Post-merge hotfixes
+
+- [X] T033 Fix Filament Notifications JS Livewire hook ordering (prevent `TypeError: e is not a function`)
+- [X] T034 Add regression test for notifications asset hook usage
+- [X] T035 Swallow Livewire cancellation promise rejections in Ops UX widget poller
+- [X] T036 Ensure Ops UX widget Alpine poller cleans up on unmount (avoid stale listeners / refresh loops)
+- [X] T037 Fix Backup Items modal refresh loop race condition (remove BackupItems relation manager polling; reduce unnecessary refresh churn)
+- [X] T038 Add regression test ensuring Backup Items table does not use Livewire polling
+
---
## Dependencies & Execution Order
diff --git a/tests/Feature/Filament/BackupItemsNoPollingTest.php b/tests/Feature/Filament/BackupItemsNoPollingTest.php
new file mode 100644
index 0000000..ae9d22f
--- /dev/null
+++ b/tests/Feature/Filament/BackupItemsNoPollingTest.php
@@ -0,0 +1,30 @@
+actingAs($user);
+
+ $tenant->makeCurrent();
+ Filament::setTenant($tenant, true);
+
+ $backupSet = BackupSet::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'name' => 'Test backup',
+ ]);
+
+ BackupItem::factory()->for($backupSet)->for($tenant)->create();
+
+ Livewire::test(BackupItemsRelationManager::class, [
+ 'ownerRecord' => $backupSet,
+ 'pageClass' => EditBackupSet::class,
+ ])
+ ->assertDontSee('wire:poll');
+});
diff --git a/tests/Feature/Filament/FilamentNotificationsAssetsTest.php b/tests/Feature/Filament/FilamentNotificationsAssetsTest.php
new file mode 100644
index 0000000..d43dd4b
--- /dev/null
+++ b/tests/Feature/Filament/FilamentNotificationsAssetsTest.php
@@ -0,0 +1,17 @@
+toContain('onFinish')
+ ->and($js)->toContain('onSuccess')
+ ->and($js)->not->toContain('onRender');
+
+ expect($shim)
+ ->toContain('TenantPilot shim')
+ ->and($shim)->toContain('Livewire.interceptMessage')
+ ->and($shim)->toContain('__tenantpilotInterceptMessageShimApplied');
+});
diff --git a/tests/Feature/Filament/LivewireInterceptShimIsLoadedTest.php b/tests/Feature/Filament/LivewireInterceptShimIsLoadedTest.php
new file mode 100644
index 0000000..ab4c9f3
--- /dev/null
+++ b/tests/Feature/Filament/LivewireInterceptShimIsLoadedTest.php
@@ -0,0 +1,9 @@
+get('/admin/login');
+
+ $response
+ ->assertSuccessful()
+ ->assertSee('js/tenantpilot/livewire-intercept-shim.js', escape: false);
+});
diff --git a/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php b/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
index 50e9d00..be49d12 100644
--- a/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
+++ b/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php
@@ -44,3 +44,10 @@
->assertDontSee('Inventory sync');
});
})->group('ops-ux');
+
+it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {
+ $contents = file_get_contents(resource_path('views/livewire/bulk-operation-progress.blade.php'));
+
+ expect($contents)->toContain('new MutationObserver');
+ expect($contents)->toContain('teardownObserver');
+})->group('ops-ux');