diff --git a/AGENTS.md b/AGENTS.md
index a733ee4b..69070605 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -315,7 +315,7 @@ This exists because the gate's promise — that passing `npm run local:gate` loc
Two consequences worth stating:
- **Do not silence a finding to satisfy the gate.** A warning is now a defect to fix. If a rule genuinely must be waived on a line, use its inline disable comment **with a one-line justification** — the same standard this document sets for `v8 ignore` and for `void` on a floating promise. Widening a `globalIgnores` or dropping a rule to make `lint` pass is not an acceptable fix.
-- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in both React scopes (`clients/web`, `clients/tui`) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
+- **A rule left at `warn` still reads wrong in an editor.** The flag makes severity irrelevant to the *gate*, not to the developer looking at a squiggle. `react-hooks/exhaustive-deps` is therefore set to **`error`** in every React scope (`clients/web`, `clients/tui`, and the root's `core/react/**` block) rather than relying on the CLI flag alone. Prefer `error` for any rule you actually intend to enforce.
## Typescript instructions
@@ -405,7 +405,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage `
const MessageContainer = Paper.withProps({ variant: "message" });
```
- State and effects
- - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces.
+ - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the `eslint-plugin-react-hooks` recommended set enforces for the web client and — since #2192 — for `core/react/` too. (`clients/tui` registers the plugin but deliberately enables only `rules-of-hooks` and `exhaustive-deps`, for the reason its own config states.)
- Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop.
- The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times.
- An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`.
diff --git a/clients/web/src/hooks/useLastPersistedSettings.test.tsx b/clients/web/src/hooks/useLastPersistedSettings.test.tsx
index 5c222b03..87249b6b 100644
--- a/clients/web/src/hooks/useLastPersistedSettings.test.tsx
+++ b/clients/web/src/hooks/useLastPersistedSettings.test.tsx
@@ -51,17 +51,31 @@ interface Harness {
* Hands the hook's API back to the test. The hook holds refs, so it has to be
* read from inside a rendered component; `setServers` re-renders it with a new
* list the way `useServers` does after a read.
+ *
+ * The returned object *delegates* rather than being the API from the first
+ * render. `resolve` is keyed on the current `servers` (#2192) instead of on a
+ * ref, so its identity changes with the list — handing back a snapshot would
+ * give every test a `resolve` closed over the initial list, and `setServers`
+ * would appear to do nothing.
*/
function harness(initial: ServerEntry[]): Harness {
- let api: LastPersistedSettings | undefined;
+ let latest: LastPersistedSettings | undefined;
function Probe({ servers }: { servers: ServerEntry[] }) {
- api = useLastPersistedSettings(servers);
+ latest = useLastPersistedSettings(servers);
return null;
}
const { rerender } = renderWithMantine();
- if (!api) throw new Error("hook did not render");
+ if (!latest) throw new Error("hook did not render");
+ const current = () => {
+ if (!latest) throw new Error("hook did not render");
+ return latest;
+ };
return {
- api,
+ api: {
+ begin: (serverId) => current().begin(serverId),
+ resolve: (serverId) => current().resolve(serverId),
+ lastWriteFailed: (serverId) => current().lastWriteFailed(serverId),
+ },
setServers: (next) => rerender(),
};
}
@@ -222,4 +236,28 @@ describe("useLastPersistedSettings", () => {
setServers([]);
expect(api.resolve("A")).toBeUndefined();
});
+ it("answers from the list of the render it is called in", () => {
+ // `useSettingsDraft` seeds its draft during render (#2192), so `resolve`
+ // is called there. Reading the effect-mirrored `serversRef` would answer
+ // from the *previous* commit's list on exactly the render where a
+ // refreshed list arrives — which is the render that seeds the draft.
+ const before = entry("A", settings({ paginatedLists: false }));
+ const after = entry("A", settings({ paginatedLists: true }));
+ const answers: Array = [];
+
+ function Probe({ servers }: { servers: ServerEntry[] }) {
+ const api = useLastPersistedSettings(servers);
+ answers.push(api.resolve("A"));
+ return null;
+ }
+
+ const { rerender } = renderWithMantine();
+ expect(answers.at(-1)).toBe(before.settings);
+
+ answers.length = 0;
+ rerender();
+
+ expect(answers.length).toBeGreaterThan(0);
+ for (const answer of answers) expect(answer).toBe(after.settings);
+ });
});
diff --git a/clients/web/src/hooks/useLastPersistedSettings.ts b/clients/web/src/hooks/useLastPersistedSettings.ts
index 86e7649c..06f231d8 100644
--- a/clients/web/src/hooks/useLastPersistedSettings.ts
+++ b/clients/web/src/hooks/useLastPersistedSettings.ts
@@ -145,16 +145,33 @@ export function useLastPersistedSettings(
const outcomesRef = useRef