build(frontend): finish the React 19 move, and hold the versions that were left in prose
react-i18next was still on ^14.1.2. The E27 S1 plan named it as "the one peer that actually needs moving — React 19 support is the 15 line", and then it did not move. The 14 line's <Trans> reads `element.ref`, removed in React 19, and its withTranslation types are built on the GLOBAL JSX namespace @types/react 19 no longer declares — both latent only because this app imports neither. 15.7.4 fixes both at the source (`c.props.ref ?? c.ref`, `React.JSX.LibraryManagedAttributes`). `reactEngine.contract.test.ts` joins its Node and Python twins and holds what was only a comment: the compiler's `target` against the installed React major, react and react-dom together, the types against the runtime, and react-i18next's floor. A stale target is not a corrupt bundle — the plugin validates the option — but it is a silent one, since tsc never sees Babel's output. pythonEngine gains the seventh spelling nobody held: `requirements.lock`'s own regeneration recipe names a python image, and wheels resolve against THAT interpreter, so a recipe left behind yields a lock that does not match the image installing it. The type lane now covers `alembic` too, for the reason the ruff lane was widened to it — migrations run on every container start, so one that raises crash-loops prod. It came back at zero new errors, so the baseline is untouched. `tests/` is deliberately still out, and the config says why. vitest.config's docblock claimed the React transform is "a no-op for the plain .ts node tests". It is not: the compiler rewrites src/lib's hooks in that lane too, which is the point of sharing one factory with the build — and worth knowing before ruling the compiler out of a divergence. Tests: useCardPager had none, in either the hook or its two callers, though its page clamp moved to render in S2 — the transformation where a non-convergent condition is a crash rather than a glitch. Four cases now, including termination on an empty list. Combobox's and Avatar's close/failed-URL cases are relabelled CHARACTERIZATION: both pass against the pre-sweep code, and the tests beside them are the ones that discriminate.
This commit is contained in:
@@ -15,8 +15,15 @@
|
||||
// `pythonVersion` is not free-standing: `/.python-version` is the single source of truth and
|
||||
// `tests/test_python_engine_contract.py` fails this gate if this file, the two Dockerfiles, the CI
|
||||
// container or ruff's target-version disagree with it.
|
||||
//
|
||||
// `include` covers `alembic` for the same reason the ruff lane was widened to it: migrations run on
|
||||
// every container start, so one that raises there crash-loops prod, and "it is generated code" is
|
||||
// not an argument when the generated code is what boots the system. `tests/` is deliberately OUT —
|
||||
// a test's job is to fail loudly when it is wrong, which is the opposite of the silent runtime
|
||||
// failure this lane exists to catch, and annotating fixtures to a checker's satisfaction is churn
|
||||
// the ratchet exists to avoid.
|
||||
{
|
||||
"include": ["app"],
|
||||
"include": ["app", "alembic"],
|
||||
"extraPaths": ["."],
|
||||
"typeCheckingMode": "basic",
|
||||
"pythonVersion": "3.12",
|
||||
|
||||
Generated
+12
-8
@@ -20,7 +20,7 @@
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-i18next": "^14.1.2"
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
@@ -5861,17 +5861,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "14.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.3.tgz",
|
||||
"integrity": "sha512-wZnpfunU6UIAiJ+bxwOiTmBOAaB14ha97MjOEnLGac2RJ+h/maIYXZuTHlmyqQVX1UVHmU1YDTQ5vxLmwfXTjw==",
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.9",
|
||||
"@babel/runtime": "^7.27.6",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"react": ">= 16.8.0"
|
||||
"i18next": ">= 23.4.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -5879,6 +5880,9 @@
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6377,7 +6381,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"lucide-react": "^0.408.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"react-i18next": "^14.1.2"
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.1",
|
||||
|
||||
@@ -247,10 +247,12 @@ describe("Combobox", () => {
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
|
||||
// The typed query is discarded when the list closes, and it happens IN the close — the clear used
|
||||
// to be an effect on `open`, which fired a render later and, in controlled mode, reported the
|
||||
// empty query outside any event (E27 S2). The list is for "find and pick": a leftover filter would
|
||||
// hide everything worth seeing the next time it opens.
|
||||
// The typed query is discarded when the list closes. CHARACTERIZATION, not the regression guard:
|
||||
// the old effect-based clear satisfies these assertions too, because RTL wraps fireEvent in act()
|
||||
// and the passive effect has flushed by the time the value is read. What the move into the close
|
||||
// funnel actually fixed is measured by the next test, which the old shape fails. This one is here
|
||||
// because the behaviour is worth pinning: the list is for "find and pick", and a leftover filter
|
||||
// would hide everything worth seeing the next time it opens.
|
||||
it("discards the typed query when the list closes, so the next open shows everything", () => {
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
|
||||
@@ -84,3 +84,17 @@ it("lints against the pinned version", () => {
|
||||
versionIn(readRoot("backend/ruff.toml"), /^target-version = "py(\d)(\d+)"$/m, "ruff.toml"),
|
||||
).toBe(pinned);
|
||||
});
|
||||
|
||||
it("resolves the lock on the pinned version", () => {
|
||||
// The seventh spelling, and the sneakiest: `requirements.lock`'s own regeneration recipe names an
|
||||
// image. Wheels and environment markers are resolved by THAT interpreter, so a recipe left behind
|
||||
// produces a lock that does not match the image installing it — the same split as the others, one
|
||||
// indirection further out, and with nothing red to show for it.
|
||||
expect(
|
||||
versionIn(
|
||||
readRoot("backend/requirements.lock"),
|
||||
/docker run [^\n]*python:(\d+)\.(\d+)-slim/,
|
||||
"requirements.lock",
|
||||
),
|
||||
).toBe(pinned);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
// The third contract test, after `nodeEngine` and `pythonEngine`, and for the same reason: a version
|
||||
// that is spelled in two places drifts, and the drift is silent until something downstream breaks.
|
||||
//
|
||||
// Here the two places are `package.json`'s React pin and `reactPlugin.ts`'s `target`, which tells
|
||||
// babel-plugin-react-compiler which React runtime to emit memoisation against. E27 S1 wrote the rule
|
||||
// as a comment — "`target` follows the installed React major and must move with it" — and the house
|
||||
// rule this codebase was built on says a rule stated in a comment must also be enforced in code. So
|
||||
// it is enforced here.
|
||||
//
|
||||
// The compiler does validate the option itself (an unknown target throws at build time), which
|
||||
// bounds the damage: the failure mode is not a corrupt bundle but a STALE target — React moves to a
|
||||
// new major and the compiler keeps emitting for the old one, with no lane saying so, because tsc
|
||||
// never sees Babel's output.
|
||||
//
|
||||
// To move React: bump `react`, `react-dom` and `@types/react` in package.json, then follow this
|
||||
// test's failures.
|
||||
const readFrontend = (rel: string) =>
|
||||
readFileSync(new URL(`../../${rel}`, import.meta.url), "utf8");
|
||||
|
||||
const pkg = JSON.parse(readFrontend("package.json")) as {
|
||||
dependencies: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
/** The major in a version range, whether it is exact (`19.3.0`) or a caret (`^19.3.0`). */
|
||||
function major(range: string, where: string): number {
|
||||
const m = /^\D*(\d+)\./.exec(range);
|
||||
expect(m, `could not read a major from ${where}: ${range}`).not.toBeNull();
|
||||
return Number(m![1]);
|
||||
}
|
||||
|
||||
const pinned = major(pkg.dependencies["react"]!, "react");
|
||||
|
||||
it("pins a plausible React major", () => {
|
||||
expect(pinned).toBeGreaterThanOrEqual(19);
|
||||
});
|
||||
|
||||
it("keeps react-dom on the same major as react", () => {
|
||||
expect(major(pkg.dependencies["react-dom"]!, "react-dom")).toBe(pinned);
|
||||
});
|
||||
|
||||
it("keeps the types on the same major as the runtime", () => {
|
||||
// Types a major ahead of the runtime make tsc green over APIs the shipped React does not have.
|
||||
expect(major(pkg.devDependencies["@types/react"]!, "@types/react")).toBe(pinned);
|
||||
expect(major(pkg.devDependencies["@types/react-dom"]!, "@types/react-dom")).toBe(pinned);
|
||||
});
|
||||
|
||||
it("compiles for the React that is actually installed", () => {
|
||||
const src = readFrontend("reactPlugin.ts");
|
||||
const m = /target:\s*"(\d+)"/.exec(src);
|
||||
// A rename or a moved line lands here. Re-point the test — the contract did not go away with it.
|
||||
expect(m, 'no `target: "<major>"` in reactPlugin.ts').not.toBeNull();
|
||||
expect(
|
||||
Number(m![1]),
|
||||
`reactPlugin.ts compiles for React ${m![1]} while package.json pins ${pinned}. The React ` +
|
||||
`Compiler emits against the runtime named by \`target\`; move it with the pin.`,
|
||||
).toBe(pinned);
|
||||
});
|
||||
|
||||
it("keeps react-i18next on a line that supports the pinned React", () => {
|
||||
// The 14 line predates React 19: its <Trans> read `element.ref` (removed in 19) and its
|
||||
// `withTranslation` types were built on the GLOBAL JSX namespace that @types/react 19 no longer
|
||||
// declares — both latent only because this app happens to use neither export. 15 is the first
|
||||
// line that handles both, so the floor is a real contract, not a tidiness preference.
|
||||
expect(major(pkg.dependencies["react-i18next"]!, "react-i18next")).toBeGreaterThanOrEqual(15);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The card layout's pager clamps an out-of-range page DURING RENDER (E27 S2), which is the right
|
||||
// shape — React discards that pass, so nothing is committed on a page that no longer exists — but
|
||||
// it is also the shape that turns a non-convergent condition into "Too many re-renders" rather than
|
||||
// a visual glitch. Neither the hook nor either of its two callers (ChannelDiscovery, Channels) had
|
||||
// a test, so nothing held either half. This file holds both: the clamp happens, and it terminates.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import { useCardPager } from "./useCardPager";
|
||||
|
||||
const rows = (n: number) => Array.from({ length: n }, (_, i) => i);
|
||||
const KEY = "siftlode.test.cardSize";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1");
|
||||
});
|
||||
|
||||
describe("useCardPager — an out-of-range page", () => {
|
||||
it("clamps to the last page that still exists when the list shrinks", () => {
|
||||
const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), {
|
||||
initialProps: { n: 30 },
|
||||
});
|
||||
act(() => result.current.pagerProps.onSizeChange(10));
|
||||
act(() => result.current.pagerProps.onPageChange(2));
|
||||
expect(result.current.pagerProps.page).toBe(2);
|
||||
expect(result.current.paged).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29]);
|
||||
|
||||
rerender({ n: 5 }); // an unsubscribe, a refetch — page 2 no longer exists
|
||||
|
||||
expect(result.current.pagerProps.page).toBe(0);
|
||||
expect(result.current.paged).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("does not spring back to the stale page when the list grows again", () => {
|
||||
const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), {
|
||||
initialProps: { n: 30 },
|
||||
});
|
||||
act(() => result.current.pagerProps.onSizeChange(10));
|
||||
act(() => result.current.pagerProps.onPageChange(2));
|
||||
|
||||
rerender({ n: 5 });
|
||||
rerender({ n: 30 });
|
||||
|
||||
// The clamp was COMMITTED, not merely displayed — `safePage` alone would have let 2 come back.
|
||||
expect(result.current.pagerProps.page).toBe(0);
|
||||
});
|
||||
|
||||
it("terminates on an empty list — the write falsifies its own condition", () => {
|
||||
const { result, rerender } = renderHook(({ n }) => useCardPager(rows(n), KEY, true), {
|
||||
initialProps: { n: 30 },
|
||||
});
|
||||
act(() => result.current.pagerProps.onSizeChange(10));
|
||||
act(() => result.current.pagerProps.onPageChange(2));
|
||||
|
||||
// totalPages floors at 1, so the clamp target is 0 and the condition is false next pass. If it
|
||||
// were not, this line would throw "Too many re-renders" instead of returning.
|
||||
rerender({ n: 0 });
|
||||
|
||||
expect(result.current.pagerProps.page).toBe(0);
|
||||
expect(result.current.paged).toEqual([]);
|
||||
});
|
||||
|
||||
it("holds still in table mode, where DataTable does its own paging", () => {
|
||||
const { result, rerender } = renderHook(({ n, on }) => useCardPager(rows(n), KEY, on), {
|
||||
initialProps: { n: 30, on: false },
|
||||
});
|
||||
act(() => result.current.pagerProps.onSizeChange(10));
|
||||
act(() => result.current.pagerProps.onPageChange(2));
|
||||
|
||||
rerender({ n: 5, on: false }); // the same shrink — but nothing is committed here
|
||||
rerender({ n: 30, on: false });
|
||||
|
||||
// `safePage` still clamps for DISPLAY while the list is short; what must not happen is the
|
||||
// clamp being written, which would lose the page for real. Growing back proves it was not.
|
||||
expect(result.current.pagerProps.page).toBe(2);
|
||||
expect(result.current.paged).toEqual(rows(30)); // and the rows are never sliced in table mode
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,11 @@ import { reactWithCompiler } from "./reactPlugin";
|
||||
// DOM). A test that needs the DOM (component render or renderHook) opts INTO jsdom per-file with a
|
||||
// // @vitest-environment jsdom
|
||||
// docblock on its first line. plugin-react is wired here so those tests get JSX + a Fast-Refresh-
|
||||
// free React transform; it is a no-op for the plain .ts node tests (they carry no JSX). jsdom is a
|
||||
// free React transform. It is NOT a no-op for the plain .ts node tests: the plugin's filter is
|
||||
// /\.[tj]sx?$/, so the React Compiler rewrites `src/lib`'s hooks in that lane too (usePref,
|
||||
// useCardPager, plexPlayerHooks all come out with `react/compiler-runtime` memo slots). That is the
|
||||
// point of sharing one factory with vite.config.ts — the gate compiles the code the way the build
|
||||
// does — and it is worth knowing before ruling the compiler out of a divergence. jsdom is a
|
||||
// devDependency referenced only via that docblock env string — knip's vitest plugin still sees it.
|
||||
export default defineConfig({
|
||||
plugins: [reactWithCompiler()],
|
||||
|
||||
Reference in New Issue
Block a user