fix(host-detail): move modals outside Suspense and auto-close Effect to parent

Modals rendered inside <Suspense> were unmounted each time the host
resource re-fetched, killing their reactive subscriptions and preventing
them from reopening. Moving them to the <div> level above <Suspense>
keeps them alive across re-fetches.

The auto-close Effect for the add-app modal is also moved from
AddAppModal to HostDetailPage so it is never recreated across
open/close cycles, avoiding the stale-value re-trigger bug.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:33:28 +02:00
parent 052711b720
commit 60e02ca453

View File

@@ -39,19 +39,6 @@ fn AddAppModal(
// Full Application structs so names are available in the selected tag list.
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
// Close the modal when the action transitions from in-flight → completed with Ok.
// Tracking the pending→false transition (rather than watching value directly) avoids
// closing the modal on mount when value still holds a previous session's Ok result.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = add_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = add_action.value().get() {
show_modal.set(false);
}
}
is_pending
});
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
@@ -258,6 +245,17 @@ pub fn HostDetailPage() -> impl IntoView {
let show_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Auto-close the add-app modal when the action completes successfully.
// Lives here (not inside AddAppModal) so it is never recreated across modal open/close cycles.
Effect::new(move |was_pending: Option<bool>| {
let is_pending = add_app_action.pending().get();
if was_pending == Some(true) && !is_pending {
if let Some(Ok(_)) = add_app_action.value().get() {
show_add_app_modal.set(false);
}
}
is_pending
});
// LocalResource avoids reading the resource outside <Suspense> during hydration,
// which would cause a mismatch between the SSR-rendered fallback and the content
@@ -309,6 +307,25 @@ pub fn HostDetailPage() -> impl IntoView {
view! {
<div class="host-detail-page">
// Modals live OUTSIDE <Suspense> so they are not unmounted when the
// host resource re-fetches (which would kill their reactive subscriptions).
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=host_id()
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
host_name=name_sig.get()
delete_action=delete_action
host_id=host_id()
show_modal=show_delete_modal
/>
})}
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
{move || host.get().map(|r| match (*r).clone() {
Err(e) => view! {
@@ -317,7 +334,6 @@ pub fn HostDetailPage() -> impl IntoView {
Ok(detail) => {
let id = detail.id;
let modal_name = detail.name.clone();
let port_count = detail.ports.len();
let app_count = detail.applications.len();
let ports = detail.ports;
@@ -433,7 +449,7 @@ pub fn HostDetailPage() -> impl IntoView {
view! {
<option
value=n.id.to_string()
selected=(n.id == current)
selected=n.id == current
>
{label}
</option>
@@ -545,16 +561,6 @@ pub fn HostDetailPage() -> impl IntoView {
</div>
</section>
// ── Add applications modal ────────────────────────
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=id
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
@@ -565,16 +571,6 @@ pub fn HostDetailPage() -> impl IntoView {
"Delete host"
</button>
</div>
// ── Delete modal (conditional) ────────────────────
{move || show_delete_modal.get().then(|| view! {
<DeleteModal
host_name=modal_name.clone()
delete_action=delete_action
host_id=id
show_modal=show_delete_modal
/>
})}
}.into_any()
}
})}