fix(workspace): stop the console remounting, and the idle polling re-renders

The Workspace page rendered `ConsoleArea` from two sites, picked by
`currentLane`. Every flip of that value unmounted the whole console
subtree and mounted it again at the other site: the terminal died, the
run history went with it, and `LaneConsolePane`'s lane-switch effect
wiped a half-typed prompt. It flipped on every page load (lanes arrive,
`refreshLanes` selects `lanes[0]`), on a lane being removed, and on any
refetch that re-picked the selection.

`lane-detail` is now always rendered and holds the only `ConsoleArea`;
the header and info block are the conditional parts. Same rule the
RunConsole already followed one level down — collapse with CSS, never by
unmounting — now enforced above it too.

Second half: every poller fed `setState` a brand-new object graph each
tick, re-rendering the lane strip, the card and the console area several
times a minute on a completely idle dashboard. `keepIfSame` keeps the
previous reference when the payload is value-identical, applied to the
5 s run/session poll, the lane list and counts, the `lane_update` WS
branch, and LaneCard's git/runtime/locks pollers.

Verified: npm run test:client (44 files / 454 tests), tsc --noEmit
clean, check-headers.sh clean. The Run screen snapshot was regenerated —
the diff is the added `<section>` wrapper and one indent level, no
content change.
This commit is contained in:
2026-09-04 10:48:22 +07:00
parent e109d2804c
commit 965c62c5cf
7 changed files with 547 additions and 462 deletions
@@ -0,0 +1,36 @@
/**
* @file keepIfSame.test.ts
* @description Covers the identity contract `keepIfSame` exists for: a polled
* payload that is value-identical must keep the PREVIOUS reference (so React
* bails out of the re-render), and any real change must yield the new one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { keepIfSame } from "../keepIfSame";
describe("keepIfSame", () => {
it("keeps the previous reference when the fetched payload is value-identical", () => {
const prev = [{ id: 1, stage: "implement" }];
const fetched = [{ id: 1, stage: "implement" }];
expect(keepIfSame(fetched)(prev)).toBe(prev);
});
it("yields the new value when anything actually changed", () => {
const prev = [{ id: 1, stage: "implement" }];
const fetched = [{ id: 1, stage: "review" }];
expect(keepIfSame(fetched)(prev)).toBe(fetched);
});
it("yields the new value out of the null a poller starts from", () => {
const fetched = { available: false };
expect(keepIfSame(fetched)(null)).toBe(fetched);
});
it("keeps null when the payload is still null", () => {
expect(keepIfSame(null)(null)).toBeNull();
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* @file keepIfSame.ts
* @description State-updater helper for polled data: keeps the previous value's
* identity when the freshly fetched payload is value-identical, so a background
* poll that found nothing new does not re-render the tree it feeds.
*
* Every poller in this app (`api.lanes.list`, `api.run.list`, `api.lanes.git`,
* …) returns a brand-new object graph on each tick. Passing that straight into
* `setState` re-renders every consumer several times a minute even when the
* dashboard is completely idle — visible as the whole page "refreshing".
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/**
* Wraps a freshly fetched value as a functional state update that returns the
* previous state unchanged when the two are value-equal.
*
* Usage: `setLanes(keepIfSame(r.lanes))` instead of `setLanes(r.lanes)`.
*
* `prev` is typed wider than `T` so the same helper works for the `T | null`
* states the pollers start out in; the cast is sound because it only happens on
* the branch where `prev` serializes identically to `next`.
*
* @param next The value just fetched.
* @returns An updater for `setState`, yielding `prev` when nothing changed.
*/
export function keepIfSame<T>(next: T): (prev: T | null | undefined) => T {
// ponytail: JSON.stringify comparison — fine for the payloads this app polls
// (lane lists, run lists, a git-facts object: tens of small records, key order
// is stable because it comes from the same serializer every time). Swap for a
// structural deep-equal if a poller ever returns something big or key-unstable.
return (prev) => (JSON.stringify(prev) === JSON.stringify(next) ? (prev as T) : next);
}