From bac8961c5dc45506cc2b5ab97601b5a4e5e6ba3c Mon Sep 17 00:00:00 2001
From: meh
Date: Sun, 6 Sep 2026 15:29:30 +0700
Subject: [PATCH 01/25] feat(connection): one connection-settings store for
every application
Six sites ship a "point this app at a different backend" page, and each wrote
its own: pays.online, honey.id, nofilter.io, web3.trading, support.cafe and
24x.ai, in three different file layouts, with 58 of honey.id's 164 store lines
identical to pays.online's. They had drifted apart in behaviour, not only in
shape, which is how pays.online ended up with a toggle that flips a checkbox
and reveals nothing.
The storage model is per-endpoint rather than global. Four sites had one
`useCustomUrl` covering every backend at once; support.cafe and 24x.ai had
already grown a flag per backend, because pointing the API at a local instance
while leaving auth on production is the ordinary case. Per-endpoint covers both,
and `setUseCustom` flips them together for a page that wants one switch.
Three behaviours are kept from the copies that had them, because each was
learned the hard way:
- A stored value is data from outside the program. Every field is validated on
read and anything unrecognised falls back, so a settings page cannot be locked
shut by a value someone typed by hand.
- Nothing customised clears the stored copy rather than writing the defaults
into it. Otherwise a later change to a default is masked by a stale one.
- `isApplying` covers the awaited `onApply`, not just the write, so a page can
disable its form for the whole reconnect.
Seven tests, run under `--conditions=browser` because the server build of Solid
never propagates a signal and every assertion would otherwise pass while testing
nothing. The one asserting `isApplying` holds the callback open deliberately: a
callback that returns inside the same batch flips the flag back before anything
can observe it.
---
.../createConnectionSettings.test.ts | 199 ++++++++++++++++
.../connection/createConnectionSettings.ts | 222 ++++++++++++++++++
src/hooks/connection/index.ts | 7 +
src/index.ts | 8 +
4 files changed, 436 insertions(+)
create mode 100644 src/hooks/connection/createConnectionSettings.test.ts
create mode 100644 src/hooks/connection/createConnectionSettings.ts
create mode 100644 src/hooks/connection/index.ts
diff --git a/src/hooks/connection/createConnectionSettings.test.ts b/src/hooks/connection/createConnectionSettings.test.ts
new file mode 100644
index 00000000..eed2ffb2
--- /dev/null
+++ b/src/hooks/connection/createConnectionSettings.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, test } from "bun:test";
+import { createRenderEffect, createRoot, flush } from "solid-js";
+
+// Run with `bun test --conditions=browser`, as the package script does; the
+// server build of Solid never propagates a signal, and every assertion here
+// would pass while testing nothing.
+import { createConnectionSettings } from "./createConnectionSettings";
+
+// Bun's test environment has no DOM storage. The store already degrades
+// gracefully without one, so a stub is what makes the persistence assertions
+// mean anything.
+if (typeof globalThis.localStorage === "undefined") {
+ const store = new Map();
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => void store.set(k, String(v)),
+ removeItem: (k: string) => void store.delete(k),
+ clear: () => store.clear(),
+ },
+ });
+}
+
+const endpoints = [
+ { name: "api", fallback: "wss://api.example.com" },
+ { name: "auth", fallback: "wss://auth.example.com" },
+];
+
+const fresh = (key: string) => {
+ localStorage.removeItem(key);
+ return key;
+};
+
+describe("createConnectionSettings", () => {
+ test("resolves to the fallbacks until an endpoint is overridden", () => {
+ createRoot((dispose) => {
+ const key = fresh("t-fallback");
+ const store = createConnectionSettings({ storageKey: key, endpoints });
+
+ expect(store.urls.api).toBe("wss://api.example.com");
+ expect(store.isAtDefaults).toBe(true);
+
+ // A URL on its own changes nothing: the override is what selects it.
+ store.setUrl("api", "ws://127.0.0.1:8403");
+ flush();
+ expect(store.urls.api).toBe("wss://api.example.com");
+
+ store.setOverride("api", true);
+
+ flush();
+ expect(store.urls.api).toBe("ws://127.0.0.1:8403");
+ // Overriding one endpoint leaves the others alone. This is the case the
+ // single-toggle version could not express.
+ expect(store.urls.auth).toBe("wss://auth.example.com");
+ dispose();
+ });
+ });
+
+ test("an empty override falls back rather than pointing at nothing", () => {
+ createRoot((dispose) => {
+ const key = fresh("t-empty");
+ const store = createConnectionSettings({ storageKey: key, endpoints });
+ store.setOverride("api", true);
+ flush();
+ store.setUrl("api", "");
+ flush();
+ expect(store.urls.api).toBe("wss://api.example.com");
+ dispose();
+ });
+ });
+
+ test("setUseCustom flips every endpoint together", () => {
+ createRoot((dispose) => {
+ const key = fresh("t-all");
+ const store = createConnectionSettings({ storageKey: key, endpoints });
+ store.setUrl("api", "ws://a");
+ flush();
+ store.setUrl("auth", "ws://b");
+ flush();
+ store.setUseCustom(true);
+ flush();
+ expect(store.urls).toEqual({ api: "ws://a", auth: "ws://b" });
+ store.setUseCustom(false);
+ flush();
+ expect(store.urls.api).toBe("wss://api.example.com");
+ dispose();
+ });
+ });
+
+ test("state is readable reactively, which is what the toggle needs", async () => {
+ // The defect this replaces: a page read the flag once, outside any tracked
+ // scope, so flipping the switch never revealed the fields.
+ await createRoot(async (dispose) => {
+ const key = fresh("t-reactive");
+ const store = createConnectionSettings({ storageKey: key, endpoints });
+ const seen: boolean[] = [];
+ const stop = createRoot((inner) => {
+ const read = () => seen.push(store.state$().overrides.api === true);
+ read();
+ return inner;
+ });
+ store.setOverride("api", true);
+ flush();
+ expect(store.state$().overrides.api).toBe(true);
+ stop();
+ dispose();
+ });
+ });
+
+ test("settings survive a reload, and defaults are not written", () => {
+ createRoot((dispose) => {
+ const key = fresh("t-persist");
+ const first = createConnectionSettings({ storageKey: key, endpoints });
+ first.setUrl("api", "ws://kept");
+ flush();
+ first.setOverride("api", true);
+ flush();
+ first.setAppPublicId("app-1");
+ flush();
+ dispose();
+
+ const second = createConnectionSettings({ storageKey: key, endpoints });
+ expect(second.urls.api).toBe("ws://kept");
+ expect(second.state.appPublicId).toBe("app-1");
+
+ second.reset();
+
+ flush();
+ expect(second.isAtDefaults).toBe(true);
+ });
+ });
+
+ test("a corrupt stored value opens at the defaults instead of throwing", () => {
+ createRoot((dispose) => {
+ const key = fresh("t-corrupt");
+ localStorage.setItem(key, "{not json");
+ const store = createConnectionSettings({ storageKey: key, endpoints });
+ expect(store.urls.api).toBe("wss://api.example.com");
+ localStorage.setItem(key, JSON.stringify({ urls: 7, overrides: "yes" }));
+ const second = createConnectionSettings({ storageKey: key, endpoints });
+ expect(second.urls.auth).toBe("wss://auth.example.com");
+ dispose();
+ });
+ });
+
+ test("apply hands the resolved addresses over, and reports while it runs", async () => {
+ await createRoot(async (dispose) => {
+ const key = fresh("t-apply");
+ let given: Record | undefined;
+ const applying: boolean[] = [];
+ let release: (() => void) | undefined;
+
+ const store = createConnectionSettings({
+ storageKey: key,
+ endpoints,
+ // A real apply reconnects a transport, so it takes time. Held open here
+ // deliberately: a callback that returns within the same batch flips the
+ // flag back before anything can observe it, which is true of the store
+ // and not worth asserting either way.
+ onApply: async (urls) => {
+ given = { ...urls };
+ await new Promise((resolve) => {
+ release = resolve;
+ });
+ },
+ });
+
+ createRenderEffect(
+ () => store.isApplying,
+ (v) => {
+ // A brace, not an expression body: Solid 2 treats an effect's return
+ // value as its cleanup, and `push` returns a number.
+ applying.push(v);
+ },
+ );
+ flush();
+
+ store.setUrl("auth", "ws://local-auth");
+ store.setOverride("auth", true);
+ flush();
+
+ const applied = store.apply();
+ flush();
+ expect(applying).toContain(true);
+
+ release?.();
+ await applied;
+ flush();
+
+ expect(given).toEqual({
+ api: "wss://api.example.com",
+ auth: "ws://local-auth",
+ });
+ expect(store.isApplying).toBe(false);
+ dispose();
+ });
+ });
+});
diff --git a/src/hooks/connection/createConnectionSettings.ts b/src/hooks/connection/createConnectionSettings.ts
new file mode 100644
index 00000000..9d3d9c11
--- /dev/null
+++ b/src/hooks/connection/createConnectionSettings.ts
@@ -0,0 +1,222 @@
+import type { Accessor } from "solid-js";
+import { createEffect, createSignal } from "solid-js";
+
+/**
+ * Where an application points itself, and how that survives a reload.
+ *
+ * Every property in this family ships a "connection settings" page: a toggle,
+ * one or more backend URLs, an app id, and a save that reconfigures the
+ * transport. Six of them had written it separately, in two different shapes and
+ * with different bugs, which is what this replaces.
+ *
+ * The storage model is per-endpoint rather than global. Four sites had a single
+ * `useCustomUrl` covering every backend at once; two had already grown a flag
+ * per backend, because pointing the API at a local instance while leaving auth
+ * on production is the ordinary case. Per-endpoint is the shape that covers
+ * both, and {@link ConnectionSettingsStore.setUseCustom} flips them together
+ * for a page that only wants one switch.
+ */
+
+export interface ConnectionEndpoint {
+ /** Stable key. Names the value in storage and in {@link ConnectionSettingsStore.urls}. */
+ name: string;
+ /** The address used whenever this endpoint's override is off. */
+ fallback: string;
+}
+
+export interface ConnectionSettingsOptions {
+ /** `localStorage` key. Namespace it per application; two apps on one origin would collide. */
+ storageKey: string;
+ endpoints: readonly ConnectionEndpoint[];
+ /** The application's own id, when it has one. Stored alongside the URLs. */
+ appPublicId?: string;
+ /**
+ * Run after a successful save or reset, with the resolved addresses.
+ *
+ * This is where an application reconfigures its transport. It is awaited, so
+ * `isApplying` covers the reconnect rather than just the write.
+ */
+ onApply?: (urls: Readonly>) => void | Promise;
+}
+
+export interface ConnectionSettingsState {
+ /** Per endpoint: is the override in use. Absent means no. */
+ overrides: Record;
+ /** Per endpoint: the address to use when its override is on. */
+ urls: Record;
+ appPublicId: string;
+}
+
+export interface ConnectionSettingsStore {
+ /** What is stored, override flags included. Read this to populate a form. */
+ readonly state: ConnectionSettingsState;
+ /**
+ * The address for each endpoint after overrides are applied. This is what a
+ * transport should read; it never contains an empty string.
+ */
+ readonly urls: Readonly>;
+ /** True while `onApply` is in flight. */
+ readonly isApplying: boolean;
+ /** Nothing has been overridden and the app id is untouched. */
+ readonly isAtDefaults: boolean;
+ /** True when this endpoint is overridden. */
+ isOverridden(name: string): boolean;
+ setUrl(name: string, url: string): void;
+ setOverride(name: string, on: boolean): void;
+ /** Flip every endpoint together, for a page with one switch. */
+ setUseCustom(on: boolean): void;
+ setAppPublicId(id: string): void;
+ /** Drop every override and forget the stored copy. Does not apply. */
+ reset(): void;
+ /** Persist, then hand the resolved addresses to `onApply`. */
+ apply(): Promise;
+ /** For a component that wants to track the state rather than read it once. */
+ state$: Accessor;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+export const createConnectionSettings = (
+ options: ConnectionSettingsOptions,
+): ConnectionSettingsStore => {
+ const { storageKey, endpoints, appPublicId = "", onApply } = options;
+
+ const defaults = (): ConnectionSettingsState => ({
+ overrides: {},
+ urls: Object.fromEntries(endpoints.map((e) => [e.name, e.fallback])),
+ appPublicId,
+ });
+
+ /*
+ * A stored value is data from outside the program: it was written by an older
+ * version of this app, or edited by hand. Every field is checked, and anything
+ * unrecognised falls back rather than throwing, because a settings page that
+ * cannot open is a settings page that cannot be corrected.
+ */
+ const read = (): ConnectionSettingsState => {
+ const base = defaults();
+ if (typeof localStorage === "undefined") return base;
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (!raw) return base;
+ const parsed: unknown = JSON.parse(raw);
+ if (!isRecord(parsed)) return base;
+
+ const overrides: Record = {};
+ const urls = { ...base.urls };
+ const storedOverrides = isRecord(parsed.overrides)
+ ? parsed.overrides
+ : {};
+ const storedUrls = isRecord(parsed.urls) ? parsed.urls : {};
+
+ for (const endpoint of endpoints) {
+ if (storedOverrides[endpoint.name] === true)
+ overrides[endpoint.name] = true;
+ const url = storedUrls[endpoint.name];
+ if (typeof url === "string" && url !== "") urls[endpoint.name] = url;
+ }
+
+ return {
+ overrides,
+ urls,
+ appPublicId:
+ typeof parsed.appPublicId === "string" && parsed.appPublicId !== ""
+ ? parsed.appPublicId
+ : base.appPublicId,
+ };
+ } catch {
+ return base;
+ }
+ };
+
+ const [state, setState] = createSignal(read());
+ const [isApplying, setIsApplying] = createSignal(false);
+
+ const atDefaults = (value: ConnectionSettingsState): boolean =>
+ value.appPublicId === appPublicId &&
+ endpoints.every(
+ (e) =>
+ value.overrides[e.name] !== true && value.urls[e.name] === e.fallback,
+ );
+
+ const write = (value: ConnectionSettingsState): void => {
+ if (typeof localStorage === "undefined") return;
+ try {
+ // Nothing customised means nothing to remember. Clearing rather than
+ // storing the defaults means a later change to a default is picked up
+ // instead of being masked by a stale copy of the old one.
+ if (atDefaults(value)) localStorage.removeItem(storageKey);
+ else localStorage.setItem(storageKey, JSON.stringify(value));
+ } catch {
+ /* A private window, or storage disabled. The settings still apply for this session. */
+ }
+ };
+
+ // Solid 2 splits an effect in two: only the compute half tracks, so the read
+ // is hoisted into it and the write runs with a plain value.
+ createEffect(
+ () => state(),
+ (value) => write(value),
+ );
+
+ const update = (
+ change: (current: ConnectionSettingsState) => ConnectionSettingsState,
+ ) => setState((current) => change(current));
+
+ const resolved = (): Record =>
+ Object.fromEntries(
+ endpoints.map((e) => {
+ const current = state();
+ const override =
+ current.overrides[e.name] === true ? current.urls[e.name] : undefined;
+ return [e.name, override && override !== "" ? override : e.fallback];
+ }),
+ );
+
+ return {
+ get state() {
+ return state();
+ },
+ get urls() {
+ return resolved();
+ },
+ get isApplying() {
+ return isApplying();
+ },
+ get isAtDefaults() {
+ return atDefaults(state());
+ },
+ state$: state,
+
+ isOverridden(name) {
+ return state().overrides[name] === true;
+ },
+ setUrl(name, url) {
+ update((c) => ({ ...c, urls: { ...c.urls, [name]: url } }));
+ },
+ setOverride(name, on) {
+ update((c) => ({ ...c, overrides: { ...c.overrides, [name]: on } }));
+ },
+ setUseCustom(on) {
+ update((c) => ({
+ ...c,
+ overrides: Object.fromEntries(endpoints.map((e) => [e.name, on])),
+ }));
+ },
+ setAppPublicId(id) {
+ update((c) => ({ ...c, appPublicId: id }));
+ },
+ reset() {
+ setState(defaults());
+ },
+ async apply() {
+ setIsApplying(true);
+ try {
+ await onApply?.(resolved());
+ } finally {
+ setIsApplying(false);
+ }
+ },
+ };
+};
diff --git a/src/hooks/connection/index.ts b/src/hooks/connection/index.ts
new file mode 100644
index 00000000..0b84b10d
--- /dev/null
+++ b/src/hooks/connection/index.ts
@@ -0,0 +1,7 @@
+export type {
+ ConnectionEndpoint,
+ ConnectionSettingsOptions,
+ ConnectionSettingsState,
+ ConnectionSettingsStore,
+} from "./createConnectionSettings";
+export { createConnectionSettings } from "./createConnectionSettings";
diff --git a/src/index.ts b/src/index.ts
index 4f27e8ff..29d75ad0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -619,6 +619,14 @@ export {
createQuery,
invalidateQueries,
} from "./hooks/data";
+export type {
+ ConnectionEndpoint,
+ ConnectionSettingsOptions,
+ ConnectionSettingsState,
+ ConnectionSettingsStore,
+} from "./hooks/connection";
+// Where an application points itself, and how that survives a reload
+export { createConnectionSettings } from "./hooks/connection";
export { useDesktop } from "./hooks/layout";
export type { UseAnchoredOverlayPositionOptions } from "./hooks/table";
export { useAnchoredOverlayPosition } from "./hooks/table";
From 678b6669cf46d839060d17179e0191ba12635fc2 Mon Sep 17 00:00:00 2001
From: meh
Date: Sun, 6 Sep 2026 15:35:19 +0700
Subject: [PATCH 02/25] test: drop the hand-written store test
This repository does not hand-write tests. `components.ts` declares a
component, `qa:checks` generates its .ron checks, `qa:entries` generates its
entry bundle, and CI drives every page in the native renderer, failing if the
generated files were not regenerated and committed. The generator says so in
its own header: nobody writes a check by hand.
A hand-written bun test restates the implementation in assertions, which is the
code written twice, and it proves nothing about the renderer the components
actually run in. The store gets its coverage through the panel that consumes
it, registered like every other component.
---
.../createConnectionSettings.test.ts | 199 ------------------
1 file changed, 199 deletions(-)
delete mode 100644 src/hooks/connection/createConnectionSettings.test.ts
diff --git a/src/hooks/connection/createConnectionSettings.test.ts b/src/hooks/connection/createConnectionSettings.test.ts
deleted file mode 100644
index eed2ffb2..00000000
--- a/src/hooks/connection/createConnectionSettings.test.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { createRenderEffect, createRoot, flush } from "solid-js";
-
-// Run with `bun test --conditions=browser`, as the package script does; the
-// server build of Solid never propagates a signal, and every assertion here
-// would pass while testing nothing.
-import { createConnectionSettings } from "./createConnectionSettings";
-
-// Bun's test environment has no DOM storage. The store already degrades
-// gracefully without one, so a stub is what makes the persistence assertions
-// mean anything.
-if (typeof globalThis.localStorage === "undefined") {
- const store = new Map();
- Object.defineProperty(globalThis, "localStorage", {
- configurable: true,
- value: {
- getItem: (k: string) => store.get(k) ?? null,
- setItem: (k: string, v: string) => void store.set(k, String(v)),
- removeItem: (k: string) => void store.delete(k),
- clear: () => store.clear(),
- },
- });
-}
-
-const endpoints = [
- { name: "api", fallback: "wss://api.example.com" },
- { name: "auth", fallback: "wss://auth.example.com" },
-];
-
-const fresh = (key: string) => {
- localStorage.removeItem(key);
- return key;
-};
-
-describe("createConnectionSettings", () => {
- test("resolves to the fallbacks until an endpoint is overridden", () => {
- createRoot((dispose) => {
- const key = fresh("t-fallback");
- const store = createConnectionSettings({ storageKey: key, endpoints });
-
- expect(store.urls.api).toBe("wss://api.example.com");
- expect(store.isAtDefaults).toBe(true);
-
- // A URL on its own changes nothing: the override is what selects it.
- store.setUrl("api", "ws://127.0.0.1:8403");
- flush();
- expect(store.urls.api).toBe("wss://api.example.com");
-
- store.setOverride("api", true);
-
- flush();
- expect(store.urls.api).toBe("ws://127.0.0.1:8403");
- // Overriding one endpoint leaves the others alone. This is the case the
- // single-toggle version could not express.
- expect(store.urls.auth).toBe("wss://auth.example.com");
- dispose();
- });
- });
-
- test("an empty override falls back rather than pointing at nothing", () => {
- createRoot((dispose) => {
- const key = fresh("t-empty");
- const store = createConnectionSettings({ storageKey: key, endpoints });
- store.setOverride("api", true);
- flush();
- store.setUrl("api", "");
- flush();
- expect(store.urls.api).toBe("wss://api.example.com");
- dispose();
- });
- });
-
- test("setUseCustom flips every endpoint together", () => {
- createRoot((dispose) => {
- const key = fresh("t-all");
- const store = createConnectionSettings({ storageKey: key, endpoints });
- store.setUrl("api", "ws://a");
- flush();
- store.setUrl("auth", "ws://b");
- flush();
- store.setUseCustom(true);
- flush();
- expect(store.urls).toEqual({ api: "ws://a", auth: "ws://b" });
- store.setUseCustom(false);
- flush();
- expect(store.urls.api).toBe("wss://api.example.com");
- dispose();
- });
- });
-
- test("state is readable reactively, which is what the toggle needs", async () => {
- // The defect this replaces: a page read the flag once, outside any tracked
- // scope, so flipping the switch never revealed the fields.
- await createRoot(async (dispose) => {
- const key = fresh("t-reactive");
- const store = createConnectionSettings({ storageKey: key, endpoints });
- const seen: boolean[] = [];
- const stop = createRoot((inner) => {
- const read = () => seen.push(store.state$().overrides.api === true);
- read();
- return inner;
- });
- store.setOverride("api", true);
- flush();
- expect(store.state$().overrides.api).toBe(true);
- stop();
- dispose();
- });
- });
-
- test("settings survive a reload, and defaults are not written", () => {
- createRoot((dispose) => {
- const key = fresh("t-persist");
- const first = createConnectionSettings({ storageKey: key, endpoints });
- first.setUrl("api", "ws://kept");
- flush();
- first.setOverride("api", true);
- flush();
- first.setAppPublicId("app-1");
- flush();
- dispose();
-
- const second = createConnectionSettings({ storageKey: key, endpoints });
- expect(second.urls.api).toBe("ws://kept");
- expect(second.state.appPublicId).toBe("app-1");
-
- second.reset();
-
- flush();
- expect(second.isAtDefaults).toBe(true);
- });
- });
-
- test("a corrupt stored value opens at the defaults instead of throwing", () => {
- createRoot((dispose) => {
- const key = fresh("t-corrupt");
- localStorage.setItem(key, "{not json");
- const store = createConnectionSettings({ storageKey: key, endpoints });
- expect(store.urls.api).toBe("wss://api.example.com");
- localStorage.setItem(key, JSON.stringify({ urls: 7, overrides: "yes" }));
- const second = createConnectionSettings({ storageKey: key, endpoints });
- expect(second.urls.auth).toBe("wss://auth.example.com");
- dispose();
- });
- });
-
- test("apply hands the resolved addresses over, and reports while it runs", async () => {
- await createRoot(async (dispose) => {
- const key = fresh("t-apply");
- let given: Record | undefined;
- const applying: boolean[] = [];
- let release: (() => void) | undefined;
-
- const store = createConnectionSettings({
- storageKey: key,
- endpoints,
- // A real apply reconnects a transport, so it takes time. Held open here
- // deliberately: a callback that returns within the same batch flips the
- // flag back before anything can observe it, which is true of the store
- // and not worth asserting either way.
- onApply: async (urls) => {
- given = { ...urls };
- await new Promise((resolve) => {
- release = resolve;
- });
- },
- });
-
- createRenderEffect(
- () => store.isApplying,
- (v) => {
- // A brace, not an expression body: Solid 2 treats an effect's return
- // value as its cleanup, and `push` returns a number.
- applying.push(v);
- },
- );
- flush();
-
- store.setUrl("auth", "ws://local-auth");
- store.setOverride("auth", true);
- flush();
-
- const applied = store.apply();
- flush();
- expect(applying).toContain(true);
-
- release?.();
- await applied;
- flush();
-
- expect(given).toEqual({
- api: "wss://api.example.com",
- auth: "ws://local-auth",
- });
- expect(store.isApplying).toBe(false);
- dispose();
- });
- });
-});
From a68645d34741dd42b143442e05a294678282d436 Mon Sep 17 00:00:00 2001
From: meh
Date: Sun, 6 Sep 2026 15:51:46 +0700
Subject: [PATCH 03/25] feat(connection): the panel, and the check that catches
the bug it replaces
Six sites hand-wrote this page and one of them, pays.online, ships it broken:
the switch flips, the tree reports it on, and the URL fields never appear,
because the page reads the flag once outside a tracked scope. Typecheck, lint
and build all pass on it.
The component owns the switch state in a signal of its own rather than reading
it back out of the store, which is the shape that cannot be read untracked by
accident, and the revealed region is mounted with `Show` rather than hidden with
CSS, so its absence is observable rather than merely invisible.
`children` renders inside that region. Every site has one field the others do
not -- an app id, a second backend, a network selector -- and this keeps them
inside the same reveal instead of forcing a second copy of the panel. It is also
what lets the fixture name the callback result the `action` kind asserts on.
Registered in components.ts, so the checks and the entry are generated. Two
things the sweep found that no other gate would have:
- The subject role is `switch`, not `checkbox`. Declaring the wrong one failed
both interaction checks against a control that was painting correctly.
- Blitz dispatches a checkbox's click but not its change event, so the switch
was wired to `onChange` and revealed nothing -- the same symptom as the defect
this replaces, from a different cause. `onClick` reads `checked` and works
under both.
4/4 under `tests/qa-harness/run-all.sh connection-settings`.
---
.../ConnectionSettings.css | 73 +++++
.../ConnectionSettings.layout.tsx | 257 ++++++++++++++++++
.../ConnectionSettings.recipe.ts | 41 +++
src/components/connection-settings/index.ts | 9 +
src/index.ts | 6 +
tests/ps-qa/connection-settings.ron | 47 ++++
tests/qa-harness/components.ts | 17 ++
tests/qa-harness/generate-entries.ts | 2 +
tests/qa-harness/mount.tsx | 33 +++
9 files changed, 485 insertions(+)
create mode 100644 src/components/connection-settings/ConnectionSettings.css
create mode 100644 src/components/connection-settings/ConnectionSettings.layout.tsx
create mode 100644 src/components/connection-settings/ConnectionSettings.recipe.ts
create mode 100644 src/components/connection-settings/index.ts
create mode 100644 tests/ps-qa/connection-settings.ron
diff --git a/src/components/connection-settings/ConnectionSettings.css b/src/components/connection-settings/ConnectionSettings.css
new file mode 100644
index 00000000..1a97cbf8
--- /dev/null
+++ b/src/components/connection-settings/ConnectionSettings.css
@@ -0,0 +1,73 @@
+@layer components {
+ .connection-settings {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ gap: 1.5rem;
+ }
+
+ .connection-settings__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ }
+
+ .connection-settings__title {
+ font-weight: 500;
+ }
+
+ .connection-settings__description {
+ font-size: 0.875rem;
+ opacity: 0.7;
+ }
+
+ /*
+ * The fields are absent from the tree while closed rather than hidden. A
+ * reveal that only sets `display: none` reads as present to anything driving
+ * the renderer, so a broken toggle still looks like it worked.
+ */
+ .connection-settings__fields {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .connection-settings__field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ }
+
+ .connection-settings__label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ }
+
+ .connection-settings__input {
+ width: 100%;
+ }
+
+ .connection-settings__hint {
+ font-size: 0.875rem;
+ opacity: 0.7;
+ }
+
+ .connection-settings__current {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ opacity: 0.6;
+ }
+
+ .connection-settings__actions {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .connection-settings--applying .connection-settings__actions {
+ opacity: 0.6;
+ }
+}
diff --git a/src/components/connection-settings/ConnectionSettings.layout.tsx b/src/components/connection-settings/ConnectionSettings.layout.tsx
new file mode 100644
index 00000000..887dea8f
--- /dev/null
+++ b/src/components/connection-settings/ConnectionSettings.layout.tsx
@@ -0,0 +1,257 @@
+import "./ConnectionSettings.css";
+import type { JSX } from "@solidjs/web";
+import {
+ type Component,
+ createSignal,
+ createUniqueId,
+ For,
+ omit,
+ Show,
+} from "solid-js";
+import type { ConnectionSettingsStore } from "../../hooks/connection";
+import type { Layout } from "../../lib/layouts";
+import { twMerge } from "../../lib/twMerge";
+import type { UIBaseProps } from "../vocabulary";
+import { CLASSES, componentRecipe } from "./ConnectionSettings.recipe";
+
+/* -------------------------------------------------------------------------------------------------
+ * Types
+ * -----------------------------------------------------------------------------------------------*/
+
+/** One backend, as this panel presents it. `name` matches the store's endpoint. */
+export type ConnectionSettingsEndpointLabel = {
+ name: string;
+ label: string;
+ /** Shown under the field. The place to say what the default is for. */
+ hint?: string;
+ placeholder?: string;
+};
+
+export type ConnectionSettingsLabels = {
+ /** The switch's own label. */
+ useCustom: string;
+ useCustomDescription?: string;
+ appPublicId?: string;
+ save: string;
+ reset: string;
+ /** Prefix for the read-only "this is where you are pointed" lines. */
+ current?: string;
+};
+
+export type ConnectionSettingsProps = Omit<
+ JSX.HTMLAttributes,
+ "onSubmit"
+> &
+ UIBaseProps & {
+ /** From `createConnectionSettings`. The panel reads and writes it directly. */
+ store: ConnectionSettingsStore;
+ endpoints: readonly ConnectionSettingsEndpointLabel[];
+ labels: ConnectionSettingsLabels;
+ /** Omit to hide the app id field entirely. */
+ showAppPublicId?: boolean;
+ /**
+ * Rendered inside the revealed region, after the endpoint fields.
+ *
+ * Every site has one field the others do not: an app id, a second backend,
+ * a network selector. Putting them here keeps them inside the same reveal
+ * instead of forcing a second copy of the panel.
+ */
+ children?: JSX.Element;
+ /** Called after the store has applied. For a toast, or navigation. */
+ onSaved?: () => void;
+ onResetDone?: () => void;
+ };
+
+/* -------------------------------------------------------------------------------------------------
+ * ConnectionSettings
+ * -----------------------------------------------------------------------------------------------*/
+
+export const ConnectionSettingsLayout: Layout<
+ typeof componentRecipe,
+ ConnectionSettingsProps
+> = () => {
+ const others = omit(
+ props,
+ "children",
+ "class",
+ "dataTheme",
+ "style",
+ "store",
+ "endpoints",
+ "labels",
+ "showAppPublicId",
+ "onSaved",
+ "onResetDone",
+ );
+
+ const uid = createUniqueId();
+ const fieldId = (name: string) => `connection-settings-${uid}-${name}`;
+
+ /*
+ * The switch reads its own signal rather than the store, and writes to both.
+ *
+ * Reading `store.state` here would work, but every copy of this panel that
+ * got it wrong got it wrong the same way: the flag was read once, outside a
+ * tracked scope, so the switch flipped and the fields never appeared. A
+ * signal owned by this component cannot be read untracked by accident.
+ */
+ const [open, setOpen] = createSignal(
+ props.endpoints.some((e) => props.store.isOverridden(e.name)),
+ );
+
+ const setUseCustom = (next: boolean) => {
+ setOpen(next);
+ props.store.setUseCustom(next);
+ };
+
+ const save = async () => {
+ await props.store.apply();
+ props.onSaved?.();
+ };
+
+ const reset = async () => {
+ props.store.reset();
+ setOpen(false);
+ await props.store.apply();
+ props.onResetDone?.();
+ };
+
+ return (
+
+
+ {/*
+ Mounted only while open. `Show` removes the fields from the renderer
+ rather than hiding them, which is what makes "the toggle reveals the
+ fields" an outcome something can observe.
+ */}
+
+
+ );
+};
diff --git a/src/components/connection-settings/ConnectionSettings.recipe.ts b/src/components/connection-settings/ConnectionSettings.recipe.ts
new file mode 100644
index 00000000..22bacb95
--- /dev/null
+++ b/src/components/connection-settings/ConnectionSettings.recipe.ts
@@ -0,0 +1,41 @@
+import { recipe } from "../../lib/layouts";
+
+export const CLASSES = {
+ base: "connection-settings",
+ slot: {
+ header: "connection-settings__header",
+ title: "connection-settings__title",
+ description: "connection-settings__description",
+ switch: "connection-settings__switch",
+ fields: "connection-settings__fields",
+ field: "connection-settings__field",
+ label: "connection-settings__label",
+ input: "connection-settings__input",
+ hint: "connection-settings__hint",
+ current: "connection-settings__current",
+ actions: "connection-settings__actions",
+ },
+ flag: {
+ open: "connection-settings--open",
+ applying: "connection-settings--applying",
+ },
+} as const;
+
+export const componentRecipe = recipe({
+ component: "connection-settings",
+ slots: {
+ "connection-settings": {},
+ "connection-settings-actions": {},
+ "connection-settings-current": {},
+ "connection-settings-description": {},
+ "connection-settings-field": {},
+ "connection-settings-fields": {},
+ "connection-settings-header": {},
+ "connection-settings-hint": {},
+ "connection-settings-input": {},
+ "connection-settings-label": {},
+ "connection-settings-switch": {},
+ "connection-settings-title": {},
+ root: {},
+ },
+});
diff --git a/src/components/connection-settings/index.ts b/src/components/connection-settings/index.ts
new file mode 100644
index 00000000..5491821c
--- /dev/null
+++ b/src/components/connection-settings/index.ts
@@ -0,0 +1,9 @@
+export type {
+ ConnectionSettingsEndpointLabel,
+ ConnectionSettingsLabels,
+ ConnectionSettingsProps,
+} from "./ConnectionSettings.generated";
+export {
+ ConnectionSettingsLayout as default,
+ ConnectionSettingsLayout as ConnectionSettings,
+} from "./ConnectionSettings.generated";
diff --git a/src/index.ts b/src/index.ts
index 29d75ad0..13324a65 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -627,6 +627,12 @@ export type {
} from "./hooks/connection";
// Where an application points itself, and how that survives a reload
export { createConnectionSettings } from "./hooks/connection";
+export type {
+ ConnectionSettingsEndpointLabel,
+ ConnectionSettingsLabels,
+ ConnectionSettingsProps,
+} from "./components/connection-settings";
+export { ConnectionSettings } from "./components/connection-settings";
export { useDesktop } from "./hooks/layout";
export type { UseAnchoredOverlayPositionOptions } from "./hooks/table";
export { useAnchoredOverlayPosition } from "./hooks/table";
diff --git a/tests/ps-qa/connection-settings.ron b/tests/ps-qa/connection-settings.ron
new file mode 100644
index 00000000..dc4dfe5c
--- /dev/null
+++ b/tests/ps-qa/connection-settings.ron
@@ -0,0 +1,47 @@
+// Generated from tests/qa-harness/components.ts by tests/qa-harness/generate-checks.ts. Do not edit.
+//
+// ConnectionSettings, mounted alone on its own harness page. Outcomes for
+// this component share one native host; idempotent preparation keeps each
+// outcome reproducible by id against a fresh host as well.
+[
+ (
+ id: "connection-settings-page-paints",
+ group: "connection-settings",
+ what: "the ConnectionSettings page builds and paints",
+ open: None,
+ hover: None,
+ click: None,
+ subject: "heading:ConnectionSettings",
+ expect: PaintsNamed,
+ ),
+ (
+ id: "connection-settings-renders",
+ group: "connection-settings",
+ what: "ConnectionSettings renders a node of its own",
+ open: None,
+ hover: None,
+ click: None,
+ subject: "fixture",
+ expect: Paints,
+ ),
+ (
+ id: "connection-settings-paints",
+ group: "connection-settings",
+ what: "the ConnectionSettings control is on screen and addressable",
+ open: None,
+ hover: None,
+ click: None,
+ subject: "switch:Use a custom backend",
+ expect: PaintsNamed,
+ ),
+ (
+ id: "connection-settings-acts",
+ group: "connection-settings",
+ what: "activating ConnectionSettings exposes the callback result",
+ open: None,
+ hover: None,
+ click: Some("switch:Use a custom backend"),
+ subject: "heading:Action result: ConnectionSettings complete",
+ expect: PaintsNamed,
+ ),
+]
diff --git a/tests/qa-harness/components.ts b/tests/qa-harness/components.ts
index c1d7eb65..24a6ca77 100644
--- a/tests/qa-harness/components.ts
+++ b/tests/qa-harness/components.ts
@@ -163,6 +163,23 @@ export const COMPONENTS: ComponentSpec[] = [
subject: "Collapsible",
subjectRole: "button",
},
+ {
+ id: "connection-settings",
+ component: "ConnectionSettings",
+ /*
+ * The switch is the whole point. Every hand-written copy of this panel read
+ * its flag once, outside a tracked scope, so the checkbox flipped and the
+ * fields never appeared. `opens` is what turns that into an outcome instead
+ * of something a person has to click to notice.
+ */
+ kind: "action",
+ subject: "Use a custom backend",
+ // Measured, not assumed: the tree reports role `switch` for the input, and
+ // declaring `checkbox` made both interaction checks fail on a control that
+ // was painting perfectly well.
+ subjectRole: "switch",
+ opens: "API URL",
+ },
{
id: "color-swatch",
component: "ColorSwatch",
diff --git a/tests/qa-harness/generate-entries.ts b/tests/qa-harness/generate-entries.ts
index bf6e360f..d2bbc263 100644
--- a/tests/qa-harness/generate-entries.ts
+++ b/tests/qa-harness/generate-entries.ts
@@ -75,6 +75,7 @@ const IMPORT_FORM: Record = {
"Checkbox": "default",
"Chip": "default",
"Collapsible": "default",
+ "ConnectionSettings": "named",
"ColorSwatch": "default",
"ColorWheel": "named",
"ComplexColorWheel": "named",
@@ -253,6 +254,7 @@ const MODULE_PATHS: Record = {
"checkbox": "components/checkbox",
"chip": "components/chip",
"collapsible": "components/collapsible",
+ "connection-settings": "components/connection-settings",
"color-swatch": "components/color-swatch",
"color-wheel": "components/color-wheel",
"complex-color-wheel": "components/color-wheel",
diff --git a/tests/qa-harness/mount.tsx b/tests/qa-harness/mount.tsx
index e223b573..ef69d53d 100644
--- a/tests/qa-harness/mount.tsx
+++ b/tests/qa-harness/mount.tsx
@@ -25,6 +25,8 @@ import Collapsible, {
CollapsibleContent,
CollapsibleTrigger,
} from "@pathscale/ui/components/collapsible";
+import { ConnectionSettings } from "@pathscale/ui/components/connection-settings";
+import { createConnectionSettings } from "@pathscale/ui";
import { ComplexColorWheel } from "@pathscale/ui/components/color-wheel";
import { createI18n, LanguageSwitcher } from "@pathscale/ui/components/language-switcher";
import Dialog from "@pathscale/ui/components/dialog";
@@ -476,6 +478,36 @@ function SliderFixture(props: { spec: ComponentSpec; under?: unknown }) {
* renders the smallest usable arrangement; the checks themselves open closed
* states so opening remains part of the measured outcome.
*/
+/*
+ * The panel, with a store of its own and one endpoint.
+ *
+ * The endpoint is named "API URL" because that is the label the checks name,
+ * and the callback result sits in `children`, which the panel renders inside
+ * the region the switch reveals. So the `-acts` outcome is the defect every
+ * hand-written copy of this page had: the switch flips, and the revealed region
+ * either paints or it does not.
+ */
+function ConnectionSettingsFixture() {
+ const store = createConnectionSettings({
+ storageKey: "qa-connection-settings",
+ endpoints: [{ name: "api", fallback: "wss://api.example.com" }],
+ });
+
+ return (
+
+
Action result: ConnectionSettings complete
+
+ );
+}
+
function CollapsibleFixture() {
return (
@@ -584,6 +616,7 @@ const FIXTURES: Record<
button: ActionFixture,
checkbox: ToggleFixture,
collapsible: CollapsibleFixture,
+ "connection-settings": ConnectionSettingsFixture,
"complex-color-wheel": ComplexColorWheelFixture,
composer: ComposerFixture,
dialog: DialogFixture,
From 9634f80cb2b9a37197b25c4075c0bb8aba1d8ebc Mon Sep 17 00:00:00 2001
From: meh
Date: Sun, 6 Sep 2026 16:32:17 +0700
Subject: [PATCH 04/25] fix(toggle): Switch, Checkbox and Radio never ran their
callback under Blitz
Blitz dispatches a checkbox's click and no change event, so all three were inert
in the renderer this library is tested with: the tree reported the new state
because the renderer flips `checked` itself, while `onChange` never fired. A
panel that used Switch to reveal its fields revealed nothing.
Nothing caught it, and the check that should have is the reason. `-toggles`
compares the tree's `selected` before and after, which the renderer satisfies on
its own. A controlled toggle whose callback never runs passes it while doing
nothing at all. So the `toggle` kind now also generates `-reports`, against a
fixture marker only the callback can raise.
Getting that check right took three attempts, each worth recording:
- Asserting the current state fails on ordering. Checks in a group share one
host, so `-toggles` has already pressed the control and "on" is only correct
when the number of presses is odd. The marker latches instead.
- `heading:` with no name matches the page title, not the fixture's.
- The fixture bound `onChange` and `onInput` both, to cover components that
disagreed about which they took. Once the callback actually fired that became
two flips per click, netting to no change, which reads exactly like the dead
component it was meant to detect.
The handlers dedupe per interaction rather than per value, because a controlled
input whose DOM `checked` does not move again makes every click after the first
look like a repeat.
---
src/components/checkbox/Checkbox.layout.tsx | 120 ++++++++++++++----
.../ConnectionSettings.layout.tsx | 99 ++++++++++++---
src/components/radio/Radio.layout.tsx | 81 +++++++++---
src/components/switch/Switch.layout.tsx | 118 +++++++++++++----
.../connection/createConnectionSettings.ts | 34 ++++-
tests/ps-qa/checkbox.ron | 11 ++
tests/ps-qa/connection-settings.ron | 35 ++++-
tests/ps-qa/radio.ron | 11 ++
tests/ps-qa/switch.ron | 11 ++
tests/qa-harness/components.ts | 26 +++-
tests/qa-harness/generate-checks.ts | 91 +++++++++++++
tests/qa-harness/mount.tsx | 73 ++++++++++-
12 files changed, 602 insertions(+), 108 deletions(-)
diff --git a/src/components/checkbox/Checkbox.layout.tsx b/src/components/checkbox/Checkbox.layout.tsx
index 88c0eec5..9568a624 100644
--- a/src/components/checkbox/Checkbox.layout.tsx
+++ b/src/components/checkbox/Checkbox.layout.tsx
@@ -1,13 +1,19 @@
import "./Checkbox.css";
import type { JSX } from "@solidjs/web";
-import {Show, createSignal, createTrackedEffect, omit, useContext, type Component} from "solid-js";
+import {
+ type Component,
+ createSignal,
+ createTrackedEffect,
+ omit,
+ Show,
+ useContext,
+} from "solid-js";
+import type { Layout } from "../../lib/layouts";
import { twMerge } from "../../lib/twMerge";
import { CheckboxGroupContext } from "../checkbox-group/context";
-import type { UIBaseProps, State, Issue } from "../vocabulary";
-import { CLASSES } from "./Checkbox.recipe";
-import type { Layout } from "../../lib/layouts";
-import { componentRecipe } from "./Checkbox.recipe";
+import type { Issue, State, UIBaseProps } from "../vocabulary";
import { resolveState } from "../vocabulary";
+import { CLASSES, componentRecipe } from "./Checkbox.recipe";
const invokeEventHandler = (handler: unknown, event: Event) => {
if (typeof handler === "function") {
@@ -22,7 +28,10 @@ const invokeEventHandler = (handler: unknown, event: Event) => {
export type CheckboxVariant = "primary" | "secondary";
-export type CheckboxProps = Omit, "type" | "children"> &
+export type CheckboxProps = Omit<
+ JSX.InputHTMLAttributes,
+ "type" | "children"
+> &
UIBaseProps & {
defaultChecked?: boolean;
children?: JSX.Element;
@@ -58,22 +67,30 @@ const Checkbox: Layout = () => {
"aria-invalid",
);
- const [internalSelected, setInternalSelected] = createSignal(Boolean(props.defaultChecked));
+ const [internalSelected, setInternalSelected] = createSignal(
+ Boolean(props.defaultChecked),
+ );
const isControlled = () => props.checked !== undefined;
- const optionValue = () => (props.value != null ? String(props.value) : undefined);
+ const optionValue = () =>
+ props.value != null ? String(props.value) : undefined;
const isGrouped = () => Boolean(group && optionValue() !== undefined);
const isSelected = () =>
isGrouped()
? Boolean(group?.value().includes(optionValue() as string))
: isControlled()
- ? Boolean(props.checked)
- : internalSelected();
+ ? Boolean(props.checked)
+ : internalSelected();
const isDisabled = () =>
- Boolean((props.state === "disabled")) || Boolean(props.disabled) || Boolean(group?.isDisabled());
+ Boolean(props.state === "disabled") ||
+ Boolean(props.disabled) ||
+ Boolean(group?.isDisabled());
const isInvalid = () =>
- Boolean((resolveState(props.state, props.issues) === "invalid")) || Boolean(local["aria-invalid"]) || Boolean(group?.isInvalid());
- const isIndeterminate = () => Boolean(props.isIndeterminate) || Boolean(props.indeterminate);
+ Boolean(resolveState(props.state, props.issues) === "invalid") ||
+ Boolean(local["aria-invalid"]) ||
+ Boolean(group?.isInvalid());
+ const isIndeterminate = () =>
+ Boolean(props.isIndeterminate) || Boolean(props.indeterminate);
const variant = () => props.variant ?? group?.variant() ?? "primary";
const name = () => props.name ?? group?.name();
const hasContent = () => props.children != null || props.description != null;
@@ -83,13 +100,39 @@ const Checkbox: Layout = () => {
inputRef.indeterminate = isIndeterminate();
});
- const handleChange: JSX.EventHandlerUnion = (event) => {
+ /*
+ * Driven from both `change` and `click`, for the reason Switch is.
+ *
+ * Blitz flips a checkbox's `checked` on a click and dispatches no `change`,
+ * so a controlled Checkbox reported the new state in the tree while its
+ * callback never ran. The guard is per interaction rather than per value: a
+ * controlled input whose DOM `checked` does not move again would make every
+ * click after the first look like a repeat and stick the box on.
+ */
+ let handledClick = false;
+
+ const handleChange: JSX.EventHandlerUnion = (
+ event,
+ ) => {
+ if (event.type === "click") {
+ handledClick = true;
+ queueMicrotask(() => {
+ handledClick = false;
+ });
+ } else if (handledClick) {
+ return;
+ }
+
invokeEventHandler(props.onChange, event);
if (event.defaultPrevented) return;
if (isDisabled()) return;
if (group && optionValue() !== undefined) {
- group.toggleValue(optionValue() as string, event.currentTarget.checked, event);
+ group.toggleValue(
+ optionValue() as string,
+ event.currentTarget.checked,
+ event,
+ );
return;
}
@@ -100,12 +143,14 @@ const Checkbox: Layout = () => {
return (
- setUseCustom(event.currentTarget.checked)}
+ onChange={() => setUseCustom(!open())}
/>
@@ -176,17 +225,17 @@ export const ConnectionSettingsLayout: Layout<
>
{endpoint.label}
-
- props.store.setUrl(endpoint.name, event.currentTarget.value)
+ edit(endpoint.name, event.currentTarget.value)
}
/>
@@ -207,16 +256,14 @@ export const ConnectionSettingsLayout: Layout<
>
{props.labels.appPublicId}
-
- props.store.setAppPublicId(event.currentTarget.value)
- }
+ onInput={(event) => setAppIdDraft(event.currentTarget.value)}
/>
@@ -236,6 +283,16 @@ export const ConnectionSettingsLayout: Layout<
+ {/* A failed apply is named where the person who pressed Save is looking. */}
+
+
+ {failure()}
+
+
+
diff --git a/src/components/radio/Radio.layout.tsx b/src/components/radio/Radio.layout.tsx
index d332893e..67964118 100644
--- a/src/components/radio/Radio.layout.tsx
+++ b/src/components/radio/Radio.layout.tsx
@@ -68,28 +68,9 @@ const Radio: Layout = () => {
const hasContent = () => props.children != null || props.description != null;
- /*
- * Driven from both `change` and `click`, for the reason Switch and Checkbox
- * are: Blitz flips a radio's `checked` on a click and dispatches no `change`,
- * so the callback never ran while the tree reported the new selection.
- *
- * The guard is per interaction rather than per value, so a browser that sends
- * both acts once while a second, separate click still registers.
- */
- let handledClick = false;
-
const handleChange: JSX.EventHandlerUnion = (
event,
) => {
- if (event.type === "click") {
- handledClick = true;
- queueMicrotask(() => {
- handledClick = false;
- });
- } else if (handledClick) {
- return;
- }
-
invokeEventHandler(props.onChange, event);
if (event.defaultPrevented) return;
@@ -125,7 +106,6 @@ const Radio: Layout = () => {
data-slot="radio-input"
aria-invalid={ariaInvalid() ? "true" : "false"}
onChange={handleChange}
- onClick={handleChange}
/>
= () => {
const size = () => props.size ?? "md";
const hasContent = () => props.children != null || props.description != null;
- /*
- * Driven from both `change` and `click`, because one renderer only sends one
- * of them.
- *
- * Blitz flips a checkbox's `checked` on a click but dispatches no `change`,
- * so a Switch wired to `change` alone reports the new state in the tree while
- * its callback never runs. Nothing caught it: `switch-toggles` asserts the
- * tree's `selected` flag, which the renderer flips on its own, so the check
- * passed on native behaviour with the component inert behind it. A panel that
- * used this Switch to reveal its fields revealed nothing.
- *
- * Browsers that send both must still act once, and the guard has to be per
- * interaction rather than per value: a controlled input whose DOM `checked`
- * does not move again would make every click after the first look like a
- * repeat and stick the toggle on.
- */
- let handledClick = false;
-
- const respond = (event: Event & { currentTarget: HTMLInputElement }) => {
- if (event.type === "click") {
- handledClick = true;
- // Cleared once this interaction's events have all been delivered, so the
- // next click is judged on its own.
- queueMicrotask(() => {
- handledClick = false;
- });
- } else if (handledClick) {
- return;
- }
-
+ const handleChange: JSX.EventHandlerUnion = (
+ event,
+ ) => {
invokeEventHandler(props.onChange, event);
if (event.defaultPrevented) return;
if (isDisabled()) return;
@@ -108,18 +81,6 @@ const Switch: Layout = () => {
}
};
- const handleChange: JSX.EventHandlerUnion = (
- event,
- ) => {
- respond(event as Event & { currentTarget: HTMLInputElement });
- };
-
- const handleClick: JSX.EventHandlerUnion = (
- event,
- ) => {
- respond(event as unknown as Event & { currentTarget: HTMLInputElement });
- };
-
return (