saving a connection now tells the other panels
reported on the first successful save: everything still showed no data and the form stayed open, and a manual page refresh was the only way through. two faults, one report. the form did not close itself — nothing cleared ?config, so a save left the settings view sitting on top of a plugin that now worked. and the two reader panels are SIBLINGS in a workspace layout: they are mounted independently, neither owns the other, and ConnectionPanel cannot call into them. so they went on showing the failure they had loaded back when no connection existed. a channel is the sanctioned answer for exactly this — docs/navigation-audit.md puts addressable state in the URL and leaves channels for "something changed, re-read it". nobody wants to link to the moment after a save. the nonce is a plain module counter, copied deliberately from officerdev's channels.ts along with the reason: setSignal(n => n + 1) applies against the value captured at render, so two bumps in one render window both compute snapshot + 1 and the second overwrites the first — and nobody re-reads. Date.now() is the same bug at millisecond scale. disconnect bumps it too. the panels must stop showing hosts they can no longer reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react";
|
import { RefreshCw, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||||
import { useClient } from "hooks/useClient";
|
import { useClient } from "hooks/useClient";
|
||||||
import { readableError } from "./errors";
|
import { readableError } from "./errors";
|
||||||
|
import { useConnectionChanged } from "./channel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificates, ordered by how soon they expire.
|
* Certificates, ordered by how soon they expire.
|
||||||
@@ -29,6 +30,7 @@ export const CertificatesPanel = () => {
|
|||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
|
||||||
|
const [connectionSignal] = useConnectionChanged();
|
||||||
const [certificates, setCertificates] = useState<Certificate[] | null>(null);
|
const [certificates, setCertificates] = useState<Certificate[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ export const CertificatesPanel = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load, connectionSignal]);
|
||||||
|
|
||||||
if (error) return <Centred>{error}</Centred>;
|
if (error) return <Centred>{error}</Centred>;
|
||||||
if (!certificates) return <Centred>Loading certificates…</Centred>;
|
if (!certificates) return <Centred>Loading certificates…</Centred>;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { readableError } from './errors';
|
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.
|
* 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<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<'test' | 'save' | 'delete' | null>(null);
|
const [busy, setBusy] = useState<'test' | 'save' | 'delete' | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -168,6 +190,7 @@ export const ConnectionPanel = () => {
|
|||||||
setView(next);
|
setView(next);
|
||||||
setSecret('');
|
setSecret('');
|
||||||
setTest(null);
|
setTest(null);
|
||||||
|
settled();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,6 +207,8 @@ export const ConnectionPanel = () => {
|
|||||||
setTest(null);
|
setTest(null);
|
||||||
setSecret('');
|
setSecret('');
|
||||||
await load();
|
await load();
|
||||||
|
// Disconnecting is a change too — the panels must stop showing hosts that are no longer reachable.
|
||||||
|
connectionChanged();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+5
-1
@@ -4,6 +4,7 @@ import { useClient } from "hooks/useClient";
|
|||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import { ConnectionPanel } from "./ConnectionPanel";
|
import { ConnectionPanel } from "./ConnectionPanel";
|
||||||
import { readableError } from "./errors";
|
import { readableError } from "./errors";
|
||||||
|
import { useConnectionChanged } from "./channel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The proxy hosts, which is the thing anybody opens this plugin to see.
|
* 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.
|
// `?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`.
|
// 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";
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -79,7 +83,7 @@ export const HostsPanel = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load, connectionSignal]);
|
||||||
|
|
||||||
const toggle = async (host: ProxyHost) => {
|
const toggle = async (host: ProxyHost) => {
|
||||||
setBusy(host.id);
|
setBusy(host.id);
|
||||||
|
|||||||
@@ -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<number>('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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user