fix(ui): add wire:key to list rows — stops "pending is not defined" (v0.9.53)

Lists rendered loop rows without a wire:key. On re-render Livewire's morph
matched rows by position and cloned them, dropping the inline Alpine x-data
(modalTrigger) scope on the buttons — the console threw "Alpine Expression
Error: pending is not defined" (and a follow-on "undefined is not valid JSON"
as the snapshot merge broke). Reported in the file manager; the same pattern
existed across the app.

Added a stable wire:key to every loop whose rows carry inline Alpine / x-modal-
trigger state: files (entries + breadcrumbs), services, the release-channel
selector, settings (users, webauthn keys, login-protection bans) and the server
detail page (hardening, firewall rules, SSH keys, fail2ban exceptions). Keys use
a stable identifier (id / fingerprint / md5 of the natural key), not the loop
index.

372 tests pass (the Livewire tests render these blades, so a broken key would
fail them); /system, /settings, /files load 200 with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation v0.9.53
boban 2026-06-22 00:44:03 +02:00
parent 91be170e78
commit 7a6d20cbed
9 changed files with 29 additions and 13 deletions

View File

@ -13,7 +13,15 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui
_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._
## [0.9.52] - 2026-06-22
## [0.9.53] - 2026-06-22
### Behoben
- **JavaScript-Fehler im Datei-Manager (und anderen Listen): „pending is not defined".** In Listen
ohne stabilen `wire:key` ordnete Livewires DOM-Abgleich die Zeilen beim Neu-Rendern nach Position
zu und verlor dabei den Alpine-Zustand der Buttons (Modal-Auslöser) — die Konsole warf „pending is
not defined" und Aktionen klemmten. Alle betroffenen Listen haben jetzt einen stabilen `wire:key`:
Datei-Manager (Einträge + Pfad), Dienste, Release-Kanal-Auswahl, Benutzer, Sicherheitsschlüssel,
IP-Sperren sowie die Server-Detailseite (Härtung, Firewall-Regeln, SSH-Schlüssel, fail2ban-Ausnahmen).
### Behoben
- **Audit-Panel auf dem Dashboard zeigte noch Roh-Codes.** Die „letzten Ereignisse" auf dem Dashboard

View File

@ -2,7 +2,7 @@
return [
// First tagged release is v0.1.0 (semantic, not -dev).
'version' => '0.9.52',
'version' => '0.9.53',
// Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod
// image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -35,9 +35,9 @@
<x-icon name="folder" class="mr-1 h-4 w-4 shrink-0 text-ink-3" />
@foreach ($this->crumbs as $crumb)
@if (! $loop->first)
<span class="font-mono text-xs text-ink-4">/</span>
<span wire:key="crumb-sep-{{ $loop->index }}" class="font-mono text-xs text-ink-4">/</span>
@endif
<button type="button" wire:click="go({{ $loop->index }})" @class([
<button type="button" wire:key="crumb-{{ $loop->index }}" wire:click="go({{ $loop->index }})" @class([
'inline-flex min-h-11 min-w-11 items-center justify-center px-2 font-mono text-xs',
'text-ink-3' => $loop->last,
'text-accent-text hover:text-accent' => ! $loop->last,
@ -78,7 +78,10 @@
@else
@foreach ($entries as $e)
@php $isDir = $e['type'] === 'dir'; @endphp
<div class="group grid grid-cols-1 gap-x-4 gap-y-2 px-4 py-3 transition-colors hover:bg-raised sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center">
{{-- Stable wire:key per row: without it Livewire's morph clones nodes by position and
the inline Alpine x-data (modalTrigger) loses its scope "pending is not defined". --}}
<div wire:key="fe-{{ md5($e['type'].'/'.$e['name']) }}"
class="group grid grid-cols-1 gap-x-4 gap-y-2 px-4 py-3 transition-colors hover:bg-raised sm:px-5 lg:grid-cols-[minmax(0,1fr)_8rem_6rem_11rem_auto] lg:items-center">
{{-- Name + icon (directories look clickable) --}}
<div class="flex min-w-0 items-center gap-3">
<span @class([

View File

@ -187,7 +187,7 @@
@endphp
{{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen);
unsupported/neutral items render muted, no warning tone. --}}
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
<div wire:key="hard-{{ $check['key'] }}" class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
<span @class([
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'],
@ -348,7 +348,7 @@
{{-- $ruleTone, NOT $tone: the page-level $tone is a closure used by the
gauges/Volumes panel; reusing the name here would clobber it. --}}
@php $ruleTone = $actTone[$rule['action'] ?? 'ALLOW'] ?? 'online'; @endphp
<div class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<div wire:key="fw-{{ md5($rule['raw'] ?? ($rule['label'] ?? $loop->index)) }}" class="flex items-center gap-3 px-4 py-2.5 sm:px-5">
<span @class([
'h-2 w-2 shrink-0 rounded-full',
'bg-online' => $ruleTone === 'online',
@ -449,7 +449,7 @@
<p class="mb-2 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('servers.whitelist_label') }}</p>
<div class="flex flex-wrap gap-1.5">
@foreach ($f2bIgnore as $ip)
<span class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
<span wire:key="f2bi-{{ md5($ip) }}" class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
{{ $ip }}
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
@ -535,7 +535,7 @@
</x-slot:actions>
<div class="divide-y divide-line">
@forelse ($sshKeys as $key)
<div class="flex items-center gap-3 px-4 py-3 sm:px-5">
<div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5">
<span class="grid h-7 w-7 shrink-0 place-items-center rounded-sm bg-raised text-ink-3">
<x-icon name="shield" class="h-3.5 w-3.5" />
</span>

View File

@ -48,7 +48,9 @@
@endfor
@else
@forelse ($list as $svc)
<div class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
{{-- wire:key: the row's x-modal-trigger buttons carry inline Alpine state; without a
stable key Livewire's morph clones by position and drops it ("pending is not defined"). --}}
<div wire:key="svc-{{ $svc['name'] }}" class="flex flex-col gap-3 px-4 py-3 sm:px-5 lg:flex-row lg:items-center">
{{-- identity --}}
<div class="flex min-w-0 flex-1 items-center gap-3">
<x-status-dot :status="$svc['status']" :ping="$svc['status'] === 'online'" class="mt-1 self-start lg:mt-0 lg:self-center" />

View File

@ -48,7 +48,7 @@
<x-panel :title="__('settings.lp_bans_title')">
<div class="divide-y divide-line">
@forelse ($bans as $ban)
<div class="flex flex-wrap items-center gap-3 py-3">
<div wire:key="ban-{{ $ban->getKey() }}" class="flex flex-wrap items-center gap-3 py-3">
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $ban->ip }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">

View File

@ -9,7 +9,7 @@
<div class="divide-y divide-line">
@forelse ($users as $user)
@php $isSelf = $user->getKey() === $currentId; @endphp
<div class="flex flex-col gap-3 py-3 sm:flex-row sm:items-center">
<div wire:key="user-{{ $user->getKey() }}" class="flex flex-col gap-3 py-3 sm:flex-row sm:items-center">
<span @class([
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
'border-accent/30 bg-accent/10 text-accent-text' => $isSelf,

View File

@ -8,7 +8,7 @@
<div class="space-y-4">
<div class="divide-y divide-line">
@forelse ($keys as $key)
<div class="flex items-center gap-3 py-3">
<div wire:key="wak-{{ $key->id }}" class="flex items-center gap-3 py-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-3">
<x-icon name="shield" class="h-4 w-4" />
</span>

View File

@ -240,7 +240,10 @@
{{-- Segmented control --}}
<div role="radiogroup" aria-label="{{ __('system.channel_radiogroup_label') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
@foreach ($channels as $key => $desc)
{{-- wire:key keeps Livewire's morph from cloning by position, which would drop the
inline Alpine x-data (modalTrigger) scope "pending is not defined". --}}
<button type="button"
wire:key="channel-{{ $key }}"
role="radio"
aria-checked="{{ $channel === $key ? 'true' : 'false' }}"
wire:click="confirmChannel('{{ $key }}')"