the picker's register link opens the form

reported as "I click register a server and nothing happens", and nothing was broken. three controls on
that screen carry the label. two are buttons that open the form; the one in the server picker — top left,
where you look first — was a plain <Link> to the servers section.

/offscale and /offscale/servers render the same view (useOffscaleSection defaults to servers when there is
no param), so from either one the click navigated to something identical. the URL moved and nothing else
did, and the only visible result was a second button with the same label further down the page.

so the link now carries ?register=1 and ServersView consumes it. the flag is stripped with replace on the
way in, which is not tidiness: left in the URL, closing the form and clicking the picker again would be a
navigation to the URL already showing, and nothing would happen — the same bug, restored.

ServersView.test.tsx pins all three: the form opens on arrival, the flag is consumed, and no flag means no
form. removing the effect fails the first two.
This commit is contained in:
2026-08-17 12:58:47 +00:00
parent 373d67e0a0
commit 58fbf12e4b
4 changed files with 124 additions and 3 deletions
+6 -2
View File
@@ -1,6 +1,6 @@
import { Check, Network, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { offscaleSectionPath } from './shared';
import { offscaleRegisterPath } from './shared';
import { useOffscaleServers } from './useOffscaleServers';
// Top-left panel of the /offscale workspace: which server everything else acts on.
@@ -45,9 +45,13 @@ export const OffscaleServerPicker = () => {
</button>
))}
{/* Goes to the Servers section AND opens the form. A link to the section alone was a no-op from the
place you are most likely to be standing: `/offscale` and `/offscale/servers` render the same
view, so the navigation was invisible and the only visible result was a second button with the
same label. */}
{servers.length === 0 && !isLoading && (
<Link
to={offscaleSectionPath('servers')}
to={offscaleRegisterPath()}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5 shrink-0" />
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react';
import { afterEach, describe, expect, test } from 'bun:test';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter, useLocation } from 'react-router';
import { render, screen, waitFor } from '@testing-library/react';
import { ServersView } from './ServersView';
import { offscaleRegisterPath } from './shared';
/**
* Whether "Register a server" opens the form — from every place that offers it.
*
* The bug this pins was reported as "I click register a server and nothing happens", and nothing was
* broken: three controls carry that label and the one in the server picker was a plain link to the Servers
* section. `/offscale` and `/offscale/servers` render the same view, so from the section you were already
* on it changed the URL and nothing else — a control that looks like the primary action and is a no-op.
*
* So the test is about the link's DESTINATION doing something, which is why it renders the view at that
* destination rather than clicking the picker: the picker lives in another panel, and what has to be true
* is that arriving there opens the form.
*
* `fetch` is stubbed rather than `mock.module('hooks/useClient')` — Bun has one module registry per run, so
* that mock is process-wide and permanent, and other files already own it.
*/
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
const stubServers = (servers: unknown[] = []) => {
globalThis.fetch = (async () =>
new Response(JSON.stringify({ servers }), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch;
};
/** Reports the live URL, so "the flag was consumed" is an assertion rather than an inference. */
const Search = () => <span data-testid="search">{useLocation().search}</span>;
const Wrap = ({ at, children }: { at: string; children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={[at]}>
{children}
<Search />
</MemoryRouter>
</QueryClientProvider>
);
const mountAt = (at: string) =>
render(
<Wrap at={at}>
<ServersView />
</Wrap>,
);
const formHeading = () => screen.queryByText('Register a Headscale server');
const search = () => screen.getByTestId('search').textContent;
describe('ServersView registration form', () => {
test('the picker link lands with the form already open', async () => {
stubServers([]);
mountAt(offscaleRegisterPath());
await waitFor(() => expect(formHeading()).not.toBeNull());
});
test('the flag is consumed, so clicking the picker link again still works', async () => {
stubServers([]);
mountAt(offscaleRegisterPath());
// Left in the URL, a second click on the picker would be a navigation to the URL already showing —
// which is the original bug, restored.
await waitFor(() => expect(search()).toBe(''));
});
test('without the flag the form stays closed', async () => {
stubServers([]);
mountAt('/offscale/servers');
// The empty state has to have rendered before "no form" means anything.
await waitFor(() => expect(screen.queryByText('No Headscale servers yet')).not.toBeNull());
expect(formHeading()).toBeNull();
});
});
+20 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
import type { OffscaleServer, OffscaleHealth } from './shared';
import { MIN_OFFSCALE_VERSION } from './shared';
import { MIN_OFFSCALE_VERSION, OFFSCALE_REGISTER_PARAM } from './shared';
import { useOffscaleServers, useOffscaleHealth, offscaleErrorMessage } from './useOffscaleServers';
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
import { ServerForm } from './ServerForm';
@@ -136,11 +137,29 @@ export const ServersView = () => {
const healthProbe = useOffscaleHealth();
const [formFor, setFormFor] = useState<'new' | OffscaleServer | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [health, setHealth] = useState<Record<number, OffscaleHealth>>({});
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
/*
The URL may ask for the form: the server picker links here with `?register=1` so its "Register a server"
opens the thing rather than landing you on a page that offers the same thing again.
The flag is CONSUMED — read once, then stripped with `replace`. Leaving it in place would break the
control a second time: close the form, click the picker again, and the URL would already say
`?register=1`, so there would be no navigation and nothing would happen. Stripping it also means a
reload does not reopen a form you dismissed.
*/
useEffect(() => {
if (searchParams.get(OFFSCALE_REGISTER_PARAM) !== '1') return;
setFormFor('new');
const next = new URLSearchParams(searchParams);
next.delete(OFFSCALE_REGISTER_PARAM);
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const test = async (id: number) => {
setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setActionError(null);
+14
View File
@@ -26,6 +26,20 @@ export const isOffscaleSection = (value: string | undefined): value is OffscaleS
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
export const offscaleSectionPath = (id: OffscaleSectionId) => `/offscale/${id}`;
/**
* A request, in the URL, to open the registration form.
*
* The server picker's "Register a server" used to be a plain link to the Servers section — which renders
* identically at `/offscale` and `/offscale/servers`, so clicking it changed the URL and nothing else and
* read as a dead control. Three things on that screen carry this label; this is what makes the first one
* do what the other two do.
*
* It is a one-shot flag rather than addressable state: ServersView consumes it and strips it, so closing
* the form and clicking again works, and a reload does not reopen a form you dismissed.
*/
export const OFFSCALE_REGISTER_PARAM = 'register';
export const offscaleRegisterPath = () => `${offscaleSectionPath('servers')}?${OFFSCALE_REGISTER_PARAM}=1`;
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
export type OffscaleServer = {
id: number;