diff --git a/web/CertificatesPanel.tsx b/web/CertificatesPanel.tsx index 066b91b..83afa41 100644 --- a/web/CertificatesPanel.tsx +++ b/web/CertificatesPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react"; import { useClient } from "hooks/useClient"; import { readableError } from "./errors"; +import { useConnectionChanged } from "./channel"; /** * Certificates, ordered by how soon they expire. @@ -29,6 +30,7 @@ export const CertificatesPanel = () => { const clientRef = useRef(client); clientRef.current = client; + const [connectionSignal] = useConnectionChanged(); const [certificates, setCertificates] = useState(null); const [error, setError] = useState(null); @@ -46,7 +48,7 @@ export const CertificatesPanel = () => { useEffect(() => { void load(); - }, [load]); + }, [load, connectionSignal]); if (error) return {error}; if (!certificates) return Loading certificates…; diff --git a/web/ConnectionPanel.tsx b/web/ConnectionPanel.tsx index 2362940..a1816c0 100644 --- a/web/ConnectionPanel.tsx +++ b/web/ConnectionPanel.tsx @@ -4,6 +4,8 @@ import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from './errors'; +import { useConnectionChanged } from './channel'; +import { useSearchParams } from 'react-router'; /** * The connection to Nginx Proxy Manager — both the setup wizard and the permanent settings page. @@ -115,6 +117,26 @@ export const ConnectionPanel = () => { const [error, setError] = useState(null); const [busy, setBusy] = useState<'test' | 'save' | 'delete' | null>(null); const [confirming, setConfirming] = useState(false); + const [, connectionChanged] = useConnectionChanged(); + const [, setParams] = useSearchParams(); + + /** + * Leave the settings view and tell the other panels to re-read. + * + * Both halves were missing and both were visible: after a successful save the form stayed open, and the + * hosts and certificates panels went on showing the failure they had loaded before a connection existed. + * The only remedy was a manual page refresh, which is what got reported. + */ + const settled = useCallback(() => { + setParams( + (p) => { + p.delete('config'); + return p; + }, + { replace: true }, + ); + connectionChanged(); + }, [setParams, connectionChanged]); const load = useCallback(async () => { try { @@ -168,6 +190,7 @@ export const ConnectionPanel = () => { setView(next); setSecret(''); setTest(null); + settled(); } catch (err) { setError(errorText(err)); } finally { @@ -184,6 +207,8 @@ export const ConnectionPanel = () => { setTest(null); setSecret(''); await load(); + // Disconnecting is a change too — the panels must stop showing hosts that are no longer reachable. + connectionChanged(); } catch (err) { setError(errorText(err)); } finally { diff --git a/web/HostsPanel.tsx b/web/HostsPanel.tsx index cf26a46..83055a3 100644 --- a/web/HostsPanel.tsx +++ b/web/HostsPanel.tsx @@ -4,6 +4,7 @@ import { useClient } from "hooks/useClient"; import { useSearchParams } from "react-router"; import { ConnectionPanel } from "./ConnectionPanel"; import { readableError } from "./errors"; +import { useConnectionChanged } from "./channel"; /** * The proxy hosts, which is the thing anybody opens this plugin to see. @@ -66,6 +67,9 @@ export const HostsPanel = () => { // `?config=1` is the settings page; an unconfigured plugin shows it whether or not it was asked for. // In the URL rather than in state, so it survives a refresh and can be linked to — `docs/navigation-audit.md`. const configuring = params.get("config") === "1"; + // Re-read when the connection is saved or removed. Named as an effect dependency below — that IS the + // subscription; there is nothing to unsubscribe from. + const [connectionSignal] = useConnectionChanged(); const load = useCallback(async () => { try { @@ -79,7 +83,7 @@ export const HostsPanel = () => { useEffect(() => { void load(); - }, [load]); + }, [load, connectionSignal]); const toggle = async (host: ProxyHost) => { setBusy(host.id); diff --git a/web/channel.ts b/web/channel.ts new file mode 100644 index 0000000..8ce852c --- /dev/null +++ b/web/channel.ts @@ -0,0 +1,43 @@ +import { useCallback, useRef } from 'react'; +import { defineChannel } from 'hooks/usePanelChannel'; + +/** + * "The connection changed — anyone reading NPM should read it again." + * + * ── Why a channel and not the URL ── + * + * `docs/navigation-audit.md` draws the line: anything ADDRESSABLE goes in the URL, and a channel is only + * for a signal — "something changed, re-read it". This is the second kind. Nobody wants to link to "the + * moment after the connection was saved", and there is nothing to select. + * + * ── Why it is needed at all ── + * + * The panels are siblings in a workspace layout. `ConnectionPanel` cannot call into `CertificatesPanel` + * and should not try — they are mounted independently and neither owns the other. Before this, saving a + * connection left both panels showing whatever they had failed to load a minute earlier, and the only fix + * a person had was a manual refresh. Reported on 2026-08-25 the first time a connection was saved through + * the form. + * + * ── The nonce is a plain counter, and that is not an accident ── + * + * Copied deliberately from `officerdev/src/channels.ts`, which records why. `setSignal(n => n + 1)` is + * WRONG here: the functional setter applies against the value captured at render, so two bumps inside one + * render window both compute `snapshot + 1` and the second silently overwrites the first — nobody + * re-reads. `Date.now()` has the same flaw in miniature, two bumps in one millisecond being one number. + * A module counter never reads React state, so it cannot be wrong however often it is called. + */ +const useConnectionChangedChannel = defineChannel('proxy-manager:connection-changed', 0); + +let nonce = 0; + +export const useConnectionChanged = () => { + const [signal, setSignal] = useConnectionChangedChannel(); + + // The setter is a fresh closure every render and this `bump` goes into dependency lists, so it is read + // through a ref — stable identity without freezing the first render's setter. + const setRef = useRef(setSignal); + setRef.current = setSignal; + const bump = useCallback(() => setRef.current(++nonce), []); + + return [signal, bump] as const; +};