From aa8d132f3ce1eb73b4fbfc52faa23d476dea9c3b Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 20 Jan 2026 21:22:32 +0100 Subject: [PATCH] feat(057): Filament v5 - notifications shim, restore vendor notifications asset, polling fixes, tests --- .../BackupItemsRelationManager.php | 25 ++----- app/Livewire/BackupSetPolicyPickerTable.php | 2 - app/Providers/Filament/AdminPanelProvider.php | 4 ++ .../js/tenantpilot/livewire-intercept-shim.js | 71 +++++++++++++++++++ .../modals/backup-set-policy-picker.blade.php | 4 +- .../livewire-intercept-shim.blade.php | 1 + .../bulk-operation-progress.blade.php | 38 +++++++++- specs/057-filament-v5-upgrade/tasks.md | 9 +++ .../Filament/BackupItemsNoPollingTest.php | 30 ++++++++ .../FilamentNotificationsAssetsTest.php | 17 +++++ .../LivewireInterceptShimIsLoadedTest.php | 9 +++ .../OpsUx/BulkOperationProgressDbOnlyTest.php | 7 ++ 12 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 public/js/tenantpilot/livewire-intercept-shim.js create mode 100644 resources/views/filament/partials/livewire-intercept-shim.blade.php create mode 100644 tests/Feature/Filament/BackupItemsNoPollingTest.php create mode 100644 tests/Feature/Filament/FilamentNotificationsAssetsTest.php create mode 100644 tests/Feature/Filament/LivewireInterceptShimIsLoadedTest.php 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');