release: 0.94.0 (improvement/e27-contract-hardening)
gate / frontend (push) Successful in 4m32s
gate / backend (push) Successful in 6m8s

This commit is contained in:
2026-09-22 00:34:12 +02:00
101 changed files with 4695 additions and 541 deletions
+23 -1
View File
@@ -1,8 +1,14 @@
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
# independent of any one machine. Two parallel jobs match the two local lanes:
# independent of any one machine. Two jobs match the two local lanes:
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
#
# They are declared side by side but they DO NOT run in parallel: the lab has a single act_runner
# with one slot, so they queue. Measured across runs #86-#91 — the backend job starts at the exact
# second the frontend one ends, every time, and the pair costs ~4 + ~6 = **~10 minutes from the
# push**. Anything that waits on this gate has to budget for the sum, not the max; the ops
# `release` read-back timed out on its first real run for believing otherwise.
#
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
@@ -87,6 +93,22 @@ jobs:
run: python -m ruff check --config backend/ruff.toml backend scripts
- name: ruff format --check
run: python -m ruff format --check --config backend/ruff.toml backend scripts
# The ninth lane (E27 S3). Run from backend/ so it resolves exactly as the local lane does —
# pyrightconfig.json's include/extraPaths are relative to the config, but the baseline is
# found relative to the working directory. New errors fail; the ~200 pre-existing ones are in
# .basedpyright/baseline.json and burn down epic by epic. Matching is per (file, rule, column
# span) plus a count, so a new error of the SAME shape in a file where a sibling was just
# fixed is absorbed — burn the hot files down first.
#
# The second step is not redundant. basedpyright TIGHTENS the baseline in place when the count
# drops, with no --writebaseline, and CI throws its workspace away — so without this a
# burn-down is green here and red on the developer's `siftlode check`, which has the same
# guard. Two gates over one tree must not answer differently.
- name: basedpyright
run: python -m basedpyright --project pyrightconfig.json
working-directory: backend
- name: basedpyright baseline is committed
run: git diff --exit-code -- backend/.basedpyright/baseline.json
- name: pytest
run: python -m pytest
working-directory: backend
+4
View File
@@ -37,3 +37,7 @@ backups/
Thumbs.db
.idea/
.vscode/
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
# reads or regenerates them, so a committed one is stale from the commit that adds it.
*.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
3.12
+17
View File
@@ -7,6 +7,23 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.94.0 — 2026-09-22
No new features, on purpose: a release spent entirely on the machinery underneath, so that whole classes of fault stop being possible rather than being fixed one at a time.
### Fixes
- Typing an impossible number into a schedule - 0 minutes, 5000 minutes - used to close the editor and quietly keep the old value, as if it had been saved. The editor now says what the allowed range is, refuses to save until the number is inside it, and hands the keyboard back to the button you opened it from when you leave with Escape.
### Internal
- Every request is now measured against a size limit before anything else reads it - before a password is checked, before an address is even resolved. Until now a stranger could make the app swallow a 32 MB upload and only then be told the password was wrong. Each address carries its own ceiling, including the ones that legitimately receive a film.
- The server's Python is type-checked by the release gate for the first time: about 36 000 lines carried type annotations that nothing had ever verified. The mistakes already there are written down so they can be worked off release by release, and a new one now fails the build.
- The app moved to React 19 and its compiler. The compiler now does, automatically and correctly, the bookkeeping that was written by hand in about 150 places - and hand-written bookkeeping is where most of this year's stale-value and stale-click bugs came from.
- The 45 places where the old code told that same checker to stop looking at a piece of work are gone. Each was either a real bug, work that should never have been in that part of the code, or a genuine exception that now states the rule it depends on in code rather than in a comment.
- The engine versions the app is built and shipped with are now pinned and checked on both sides, the way the JavaScript side was fixed in the previous release.
- Four adversarial review rounds closed the work: 22 findings, then 13, then 6, then 2 - and the last two were in the review's own tests, not in the app. Everything found was fixed here, and every regression test was proven to fail with its own fix taken out.
## 0.93.2 — 2026-09-20
An amount on a Plex film reads the same in every browser.
+33 -27
View File
@@ -7,17 +7,18 @@ worse than no rule — if you change how the code works, change this file in the
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
provider.
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
router-free navigation provider.
## The gate — never merge or ship red
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + typecheck (3 tsc projects) + eslint + prettier
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
- Full gate (agents with the private ops dispatcher): `siftlode check`.
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
## Workflow
@@ -57,7 +58,7 @@ how it gets built. Concretely:
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
@@ -74,29 +75,34 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
## Canonical patterns — when you have a choice, pick THIS
| Area | Use | Not |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| Area | Use | Not |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+), else a latest-value ref | an excused dep list asserting the same thing in prose |
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
## House rules that bite (learned from real bugs)
+1 -1
View File
@@ -1 +1 @@
0.93.2
0.94.0
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
"""The global request-body cap (SEC-20).
**The hole.** FastAPI reads a declared body IN FULL before auth or any other dependency runs — the
body parameter has to be parsed to be passed in. So `POST /auth/password-login` drains a 32 MiB
upload from an anonymous caller and only then answers 401. 70 of the 72 body-declaring routes behave
the same way: one unauthenticated client can spend the process's memory and time at will.
**Why a middleware and not `capped_body` on every route.** `routes/_common.py:capped_body` is the
right tool where a route genuinely takes bulk bytes, and the two routes that use it use it
correctly. But putting it on all 70 others would (a) fight the canonical "Request body → a Pydantic
model" row, since capping means taking the body as raw bytes and parsing it by hand, and (b) leave
the NEXT route added uncovered, because nothing would remind its author. A default that has to be
opted into is not a default. This module inverts it: the cap applies to everything, and the few
routes that need more opt OUT, in one list, next to the code that reads it.
**Why raw ASGI and not `@app.middleware("http")`.** Starlette's BaseHTTPMiddleware cannot wrap
`receive`, so it can only read `Content-Length` — which a client may omit entirely (chunked) or
simply understate. The bytes have to be counted as they arrive, which is the same rule `capped_body`
already applies per-route, and counting needs the receive channel.
**Why the refusal is byte-identical to `capped_body`'s.** Two shapes for one fact is how the
`cancelled`/`canceled` bug happened. The 413 body here is built through the same `JSONResponse`
FastAPI renders `HTTPException(413, {"code": "too_large"})` with — not a hand-rolled `json.dumps`,
whose default separators differ by two bytes — and `tests/test_body_limit.py` compares the RAW BYTES
of the two against each other rather than against a literal, so they cannot drift apart. The same
reasoning decides where the middleware is registered (`main.py`): just inside CORS, so this 413
carries the same headers `capped_body`'s does.
**No admin knob, deliberately.** The ceiling is a constant, not a `sysconfig` value: an attacker-
facing limit that an admin can raise from the UI is a limit that can be raised by whoever gets the
admin session, and nobody has asked to tune it. If a self-hoster ever needs a different ceiling it
belongs in `config.py` (env, set before the process starts), not in the database.
"""
import re
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app import debugsession
from app.config import settings
# 1 MiB. The largest body any route on the default ceiling legitimately takes is 512 KiB (the client
# log sink, `routes/diagnostics.py:BODY_MAX_BYTES`), so this leaves 2x headroom — a ceiling that
# refuses a legitimate request would be a worse bug than the one this module fixes. That ratio is
# the load-bearing fact, so `test_body_limit.py` holds it rather than the figure above.
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# The routes that need MORE than the default, as (method, path TEMPLATE) → their own ceiling. The
# template, not a concrete path, so the list reads like the route decorators it mirrors and
# `test_body_limit.py` can assert that each one still resolves to a real route: a renamed route
# fails the gate instead of silently losing its allowance in prod.
#
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
# would mean "this route is counted by something else" — which is only true when the route's own
# limit applies to the bytes as they ARRIVE. That holds for the screenshot route (no declared body
# parameter: `capped_body` streams it, and the owner check resolves first) and it does NOT hold for
# the upload route: `file: UploadFile = File(...)` is a declared body, so FastAPI parses and spools
# the whole multipart part to a temp file BEFORE the first dependency runs — `require_human`
# included. Its in-handler cap therefore measures bytes that have already landed. Left uncounted,
# an anonymous caller could stream without bound into the container's tmpfs; counted here, the
# process refuses at the same number the route itself enforces, before any of it is stored.
#
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
# default, so listing it could only widen what it accepts.
# What a multipart envelope costs on top of the file itself: the boundary lines, the part headers
# and the sibling `display_name` field. The edge counts RAW BODY bytes while the upload route counts
# the DECODED file, so without this allowance a file of exactly the configured maximum — the number
# the admin page advertises, and the first thing anyone testing the limit reaches for — is refused
# one layer before the route that would have accepted it. Generous on purpose: its job is to keep
# the two layers from disagreeing at the boundary, not to be a second limit.
MULTIPART_ENVELOPE_ALLOWANCE = 64 * 1024
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded so it can
# never exceed this (see sysconfig.SPECS), which is what stops an admin from raising the route's
# own cap past the edge and collecting 413s from a layer the admin page never mentions.
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes
+ MULTIPART_ENVELOPE_ALLOWANCE,
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
}
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
# as — built through the same JSONResponse so the separators match too, and asserted against a real
# route's raw bytes in `test_body_limit.py` rather than against a literal here.
TOO_LARGE_BODY = JSONResponse({"detail": {"code": "too_large"}}, status_code=413).body
_PATH_PARAM = re.compile(r"\{[^/{}]+\}")
def template_to_regex(template: str) -> re.Pattern[str]:
"""`/api/debug-sessions/{sid}/screenshots` → a regex matching one concrete path.
A path parameter matches `[^/]+` — one segment, never across a `/`, so `{sid}` cannot swallow a
deeper path and hand an exemption to a route that was never granted one.
"""
literals = [re.escape(part) for part in _PATH_PARAM.split(template)]
return re.compile("^" + "[^/]+".join(literals) + "$")
def _declared_length(scope: Scope) -> int | None:
"""The `Content-Length` header as an int, or None when absent or unparseable (a chunked body has
none, and a malformed one is not a number we may trust — both fall through to counting)."""
for name, value in scope.get("headers", ()):
if name == b"content-length":
try:
return int(value)
except ValueError:
# silent-ok: a malformed Content-Length is the caller's doing, not a fault of ours,
# and it is about to be answered anyway — returning None drops the request into the
# counting path, which refuses on the real bytes. Logging it would hand an anonymous
# client a free line in our log for every request it cares to malform.
return None
return None
async def _send_too_large(send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(TOO_LARGE_BODY)).encode()),
],
}
)
await send({"type": "http.response.body", "body": TOO_LARGE_BODY})
class BodyLimitMiddleware:
"""Refuses any request body over its route's ceiling, before the application sees it.
Registered just inside CORS in `main.py` — so the 413 is decorated like every other response
and a browser can actually read it — but outside the session, the setup gate and every auth
dependency, none of which run for a request that is already too large.
Every request gets a finite ceiling: `ROUTE_MAX_BODY_BYTES` raises it for the two routes that
genuinely take bulk bytes, and there is no path that skips counting altogether.
"""
def __init__(
self,
app: ASGIApp,
*,
limit: int = DEFAULT_MAX_BODY_BYTES,
routes: dict[tuple[str, str], int] | None = None,
) -> None:
self.app = app
self.limit = limit
self._routes = tuple(
(method, template_to_regex(path), cap)
for (method, path), cap in (ROUTE_MAX_BODY_BYTES if routes is None else routes).items()
)
def limit_for(self, method: str, path: str) -> int:
"""This request's ceiling: the route's own if it has one, else the global default. Every
request gets a finite number — there is no path through here that is simply not counted."""
for m, rx, cap in self._routes:
if m == method and rx.match(path):
return cap
return self.limit
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
limit = self.limit_for(scope.get("method", ""), scope.get("path", ""))
declared = _declared_length(scope)
if declared is not None and declared > limit:
# Refused on the declaration alone: nothing downstream is called, and not one byte of
# the body is read.
await _send_too_large(send)
return
seen = 0
refused = False
started = False
async def counting_receive() -> Message:
nonlocal seen, refused
message = await receive()
if message["type"] != "http.request":
return message
seen += len(message.get("body", b""))
if seen > limit:
# The app is blocked on this receive, so it has not answered yet. Telling it the
# client went away is the only in-band way to stop it reading: `request.stream()`
# turns an `http.disconnect` into ClientDisconnect, which unwinds the handler. Our
# own 413 goes out below, once the app has finished unwinding.
refused = True
return {"type": "http.disconnect"}
return message
async def guarded_send(message: Message) -> None:
nonlocal started
if refused and not started:
return # discard the app's answer to the disconnect; the 413 below is the response
started = True
await send(message)
try:
await self.app(scope, counting_receive, guarded_send)
except Exception: # noqa: BLE001 — only swallowed when WE caused it, see below
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
# consequence of the disconnect we just faked, and the caller must get the 413 rather
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
# untouched.
if not refused:
raise
if refused and not started:
await _send_too_large(send)
+6 -2
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Literal, get_args, get_origin
from pydantic import ValidationInfo, field_validator, model_validator
from pydantic import Field, ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
@@ -247,7 +247,11 @@ class Settings(BaseSettings):
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the
# user's overall byte footprint is still governed by download_default_max_bytes.
download_upload_max_bytes: int = 524_288_000 # 500 MiB
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
# real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0
# would refuse every upload, including the admin page's own way back. To lift the cap in
# practice, set a large number.
download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB
# Retention: an asset expires this many days after its last access; the GC job then
# deletes it (once no job references it) after an additional grace window.
download_retention_days: int = 30
+31 -10
View File
@@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import DataError
from starlette.middleware.sessions import SessionMiddleware
from app import bodylimit
from app import logsink
from app import state
from app import sysconfig
@@ -189,15 +190,6 @@ app.add_middleware(
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
@@ -217,6 +209,34 @@ async def setup_gate(request, call_next):
return await call_next(request)
# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL
# before the first dependency, so without this the anonymous login route is a free memory/time sink.
#
# WHERE it sits is the whole design, and it is pinned by `test_body_limit.py` rather than by this
# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a
# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly
# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints:
#
# * inside CORS and the security headers, so the 413 is decorated like every other response. A
# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang,
# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what
# bodylimit.py exists to avoid.
# * outside the setup gate, the session and every auth dependency under the router — none of which
# run for a body that is already too large. The setup gate matters specifically: on an instance
# that is not yet configured it opens a DB session per request, which is precisely the anonymous
# pre-auth work SEC-20 exists to remove.
app.add_middleware(bodylimit.BodyLimitMiddleware)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def static_cache_headers(request, call_next):
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
@@ -259,7 +279,8 @@ async def security_headers(request, call_next):
# The access log (app/logs.py): request id, route template, status, duration — one line, never a
# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
# request, including what every layer above does — the body cap included, so a 413 is still logged
# with its request id and duration. Being outermost also puts it OUTSIDE SessionMiddleware,
# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
# place a session is actually resolved into an account) rather than from `request.session`.
app.middleware("http")(log_requests)
+46 -7
View File
@@ -140,7 +140,21 @@ SPECS: tuple[ConfigSpec, ...] = (
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
ConfigSpec("download_upload_max_bytes", "int", "downloads", "download_upload_max_bytes", min=1),
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over roughly
# this number at the edge, before the route's own check ever sees it. An admin allowed to raise
# the knob past the edge would get 413s from a layer the admin page never mentions, so the two
# are pinned together here and `tests/test_body_limit.py` holds them that way. `_coerce` also
# REJECTS a stored value outside these bounds on read (falling back to the env default, with a
# warning) — neither bound has always been what it is now, so a legal-when-written override can
# be sitting in the database already.
ConfigSpec(
"download_upload_max_bytes",
"int",
"downloads",
"download_upload_max_bytes",
min=1,
max=settings.download_upload_max_bytes,
),
ConfigSpec(
"download_retention_days", "int", "downloads", "download_retention_days", min=0, max=3_650
),
@@ -236,9 +250,29 @@ def _choice(s: ConfigSpec, raw: object) -> str:
return option
class OutOfRange(ValueError):
"""A stored int override that no longer satisfies its spec's bounds."""
def _coerce(s: ConfigSpec, raw: str):
if s.type == "int":
return int(raw)
# Bounds are checked on READ, not only in `set_value`. Bounds get ADDED to a spec
# (download_upload_max_bytes gained a `max` when SEC-20 started enforcing the same number at
# the edge; its `min` went 0 → 1 during E7) and env defaults get LOWERED, and either leaves
# a stored value that was legal when it was written. Serving it is how one layer ends up
# acting on a number another layer refuses, with the admin page showing a value outside the
# range it renders.
#
# It raises rather than CLAMPING, and that distinction cost a round: clamping is a silent
# rewrite, and silently rewriting is exactly what this module refuses to do elsewhere. The
# case that settles it is a stored 0 for download_upload_max_bytes — legal when `min` was 0,
# and meaning "no per-file cap" to the route that reads it. Clamped to `min=1` it becomes a
# ONE-BYTE cap and uploads stop working with nothing in the log. Raising drops it into the
# same fallback the uncoercible path uses: the env default, and one warning.
v = int(raw)
if (s.min is not None and v < s.min) or (s.max is not None and v > s.max):
raise OutOfRange(f"{v} is outside [{s.min}, {s.max}]")
return v
if s.type == "bool":
return raw == "true"
if s.type == "choice":
@@ -291,14 +325,19 @@ def get(db: Session, key: str):
return _coerce(s, raw)
except (TypeError, ValueError) as exc:
# The admin set this in the UI and it is quietly not in force: the app runs on the env
# default while the settings page goes on showing the value that was saved.
# default while the settings page goes on showing the value that was saved. An OutOfRange is
# the same situation arrived at from the other direction — the value was fine when it was
# written and the spec moved under it — so it gets the same treatment and its own code.
event = (
"sysconfig.override_out_of_range"
if isinstance(exc, OutOfRange)
else "sysconfig.override_uncoercible"
)
if (s.key, raw) not in _coerce_warned:
_coerce_warned.add((s.key, raw))
log.warning(
"a stored setting could not be coerced; the default is used instead",
extra=logs.event(
"sysconfig.override_uncoercible", key=s.key, reason=type(exc).__name__
),
"a stored setting is not usable; the default is used instead",
extra=logs.event(event, key=s.key, reason=type(exc).__name__),
)
return _default(s)
+31
View File
@@ -0,0 +1,31 @@
// The backend type-check lane (E27 S3) — the gate's ninth lane, and the first thing that has ever
// type-checked the ~36k lines of `app/`. Read as JSONC: pyright accepts comments here.
//
// basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land
// ~190-200 errors with the same distribution, so the choice was made on the things that differ —
// basedpyright is ~2x faster, needs no MYPYPATH to resolve our `app.*` imports, and has a FIRST-CLASS
// baseline (`--writebaseline`), which is the whole ratchet. mypy would have needed a third-party tool
// for that.
//
// It runs in the pytest test image, never on the host: `requirements.lock` cannot be installed on
// Windows at all (uvloop refuses to build there), so a host run would type-check against absent
// third-party packages and report nonsense. The version is pinned in requirements-dev.txt, which the
// image installs — so the lane's version IS the pin, with no host-vs-pin check to drift.
//
// `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", "alembic"],
"extraPaths": ["."],
"typeCheckingMode": "basic",
"pythonVersion": "3.12",
"pythonPlatform": "Linux"
}
+4
View File
@@ -4,6 +4,10 @@
# backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2:
# Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the
# app's own httpx with a deprecation warning on every run.
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it
# writes is keyed to the diagnostics THIS version produces.
basedpyright==1.40.1
pytest==8.4.2
ruff==0.15.21
httpx2==2.13.0
+4
View File
@@ -7,6 +7,10 @@
# the repo wrap at the same width, and it's the width `ruff format` enforces (gated via
# `ruff format --check` alongside `ruff check`).
line-length = 100
# The interpreter this code actually runs on, so ruff judges syntax and upgrade hints against the
# right one. Left unset it falls back to a default that has nothing to do with us. `/.python-version`
# is the single source of truth; tests/test_python_engine_contract.py holds this to it.
target-version = "py312"
[lint]
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it:
+422
View File
@@ -0,0 +1,422 @@
"""SEC-20 — the global request-body cap (`app/bodylimit.py`).
Two halves, deliberately kept apart:
* the MIDDLEWARE tests drive a toy ASGI app, so they pin the mechanism (declared length, counted
bytes, the exemption match) with no router, no auth and no database in the way;
* the APP tests drive the real application, so they pin that the middleware is actually wired in,
sits ahead of the auth dependencies, and refuses in the same shape `capped_body` does.
The pair that matters most is "an oversized body is refused" together with "a normal body still
gets through": the first alone would pass just as well if the cap refused *everything*.
"""
import ast
import json
import pathlib
import pytest
from fastapi.testclient import TestClient
from starlette.requests import Request
from app import bodylimit, debugsession, sysconfig
from app.config import settings
from app.models import SystemConfig
from app.bodylimit import DEFAULT_MAX_BODY_BYTES, BodyLimitMiddleware
from app.routes import diagnostics as diagnostics_routes
SMALL = b"x" * 16
OVER = b"x" * (DEFAULT_MAX_BODY_BYTES + 1)
async def _reading_app(scope, receive, send):
"""A toy app that reads the whole body the way a route does, then reports its length.
It uses a real Starlette `Request`, so the faked `http.disconnect` reaches it as the real
ClientDisconnect a real handler would see — the middleware's exception path is exercised, not
stubbed around.
"""
body = await Request(scope, receive).body()
payload = json.dumps({"read": len(body)}).encode()
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"application/json")],
}
)
await send({"type": "http.response.body", "body": payload})
def _client(**kwargs) -> TestClient:
return TestClient(BodyLimitMiddleware(_reading_app, **kwargs))
def _chunks(total: int, size: int = 64 * 1024):
"""A body with NO Content-Length: httpx sends an iterator chunked, which is exactly the shape a
declared-length check cannot see."""
sent = 0
while sent < total:
n = min(size, total - sent)
yield b"x" * n
sent += n
# --- the mechanism ------------------------------------------------------------------------------
def test_a_declared_length_over_the_cap_is_refused_without_reading_the_body():
r = _client().post("/anything", content=OVER)
assert r.status_code == 413
assert r.content == bodylimit.TOO_LARGE_BODY
def test_a_body_under_the_cap_is_passed_through_untouched():
# The mutation guard for the test above: if the cap refused everything, this would fail.
r = _client().post("/anything", content=SMALL)
assert r.status_code == 200
assert r.json() == {"read": len(SMALL)}
def test_a_chunked_body_is_refused_on_the_COUNTED_bytes():
# No Content-Length at all, so the only thing that can stop this is counting as it arrives.
r = _client().post("/anything", content=_chunks(DEFAULT_MAX_BODY_BYTES + 64 * 1024))
assert r.status_code == 413
assert r.content == bodylimit.TOO_LARGE_BODY
def test_a_chunked_body_under_the_cap_still_arrives_whole():
size = DEFAULT_MAX_BODY_BYTES - 1
r = _client().post("/anything", content=_chunks(size))
assert r.status_code == 200
assert r.json() == {"read": size}
def test_an_understated_content_length_does_not_buy_a_pass():
# The declared-length check is the cheap half; a client that lies must still be counted out.
r = _client().post("/anything", content=OVER, headers={"content-length": "10"})
assert r.status_code == 413
def test_exactly_at_the_cap_is_allowed():
size = DEFAULT_MAX_BODY_BYTES
r = _client(limit=size).post("/anything", content=b"x" * size)
assert r.status_code == 200
assert r.json() == {"read": size}
# --- the per-route ceilings ---------------------------------------------------------------------
BIG = {("POST", "/api/things/{tid}/blob"): DEFAULT_MAX_BODY_BYTES * 4}
def test_a_route_with_a_raised_ceiling_may_send_a_body_over_the_global_cap():
r = _client(routes=BIG).post("/api/things/abc/blob", content=OVER)
assert r.status_code == 200
assert r.json() == {"read": len(OVER)}
def test_a_raised_ceiling_is_still_a_ceiling():
"""The difference between this and an exemption, and the reason the list is a mapping: a route
that needs more bytes does not thereby get unlimited bytes. Left uncounted, the upload route
would let an ANONYMOUS caller stream without bound — FastAPI parses a declared body before the
first dependency, so `require_human` has not run yet when the bytes land."""
over = b"x" * (BIG[("POST", "/api/things/{tid}/blob")] + 1)
r = _client(routes=BIG).post("/api/things/abc/blob", content=over)
assert r.status_code == 413
assert r.content == bodylimit.TOO_LARGE_BODY
def test_a_raised_ceiling_counts_a_chunked_body_too():
cap = BIG[("POST", "/api/things/{tid}/blob")]
r = _client(routes=BIG).post("/api/things/abc/blob", content=_chunks(cap + 64 * 1024))
assert r.status_code == 413
def test_the_raised_ceiling_is_scoped_to_its_method_and_its_path():
client = _client(routes=BIG)
# Same path, different method.
assert client.put("/api/things/abc/blob", content=OVER).status_code == 413
# A neighbour that merely starts the same way.
assert client.post("/api/things/abc/blob/extra", content=OVER).status_code == 413
def test_a_path_parameter_cannot_swallow_a_slash():
# `{tid}` matching greedily across `/` would hand the raised ceiling to routes without one.
mw = BodyLimitMiddleware(_reading_app, routes={("POST", "/a/{tid}/b"): 99})
assert mw.limit_for("POST", "/a/one/b") == 99
assert mw.limit_for("POST", "/a/one/two/b") == DEFAULT_MAX_BODY_BYTES
def test_every_request_gets_a_finite_ceiling():
"""There is no pass-through path. A route that is not listed gets the default; one that is
listed gets its own; nothing is simply not counted."""
mw = BodyLimitMiddleware(_reading_app)
assert mw.limit_for("GET", "/api/anything") == DEFAULT_MAX_BODY_BYTES
for (method, template), cap in bodylimit.ROUTE_MAX_BODY_BYTES.items():
assert cap > DEFAULT_MAX_BODY_BYTES # the admission rule: raised, never lowered
assert mw.limit_for(method, template.replace("{sid}", "abc")) == cap
def _declared_routes(root) -> set[tuple[str, str]]:
"""Every (method, path template) the app serves.
It has to RECURSE: since FastAPI 0.141 / Starlette 1.6 an `include_router` leaves an opaque
`_IncludedRouter` in `app.routes` and keeps the real routes under its `original_router`, instead
of flattening them into the parent as older versions did. Reading `app.routes` alone finds 35
entries and none of ours; walking the tree finds all 252.
"""
found: set[tuple[str, str]] = set()
stack = [root]
while stack:
node = stack.pop()
for route in getattr(node, "routes", ()) or ():
inner = getattr(route, "original_router", None)
if inner is not None:
stack.append(inner)
continue
for method in getattr(route, "methods", None) or ():
found.add((method, route.path))
return found
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
def test_every_listed_template_still_resolves_to_a_real_route(method, template):
"""The drift guard. The list names route templates that live in another file; a renamed route
must fail the gate here, not lose its allowance silently in production."""
from app.main import app
assert (method, template) in _declared_routes(app)
def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
"""The headroom the module's docblock claims, held as a RATIO rather than as a figure in prose.
The client log sink caps itself at 512 KiB — below the default, which is why it is not listed:
the global ceiling never fires on it and raising it could only widen what it accepts.
`<=` alone would not hold the claim. The docblock calls the 2x margin load-bearing precisely
because a ceiling that refuses a legitimate request is a worse bug than the one this module
fixes, and a margin of 1x is no margin at all.
"""
assert diagnostics_routes.BODY_MAX_BYTES * 2 <= DEFAULT_MAX_BODY_BYTES
assert not any(
path.startswith("/api/diagnostics") for _, path in bodylimit.ROUTE_MAX_BODY_BYTES
)
# Every `capped_body` call site in the routers, as (module, limit constant). A route that reads its
# own body declares the size it MEANS to accept; if that is above the global default and its route
# is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have taken —
# silently, in production, on a path nobody tested. The point of the list is the third entry.
CAPPED_BODY_SITES = {
("diagnostics.py", "BODY_MAX_BYTES"),
("debug_sessions.py", "debugsession.SCREENSHOT_MAX_BYTES"),
}
def test_every_capped_body_site_is_known_and_fits_under_its_ceiling():
"""Parsed, not grepped. A regex over the call text has to guess how the arguments are spelled,
and the shapes it guesses wrong — a bare `4 * 1024 * 1024`, a parameter not named `request` —
are the natural ways the NEXT site gets written, so a miss reads as "no new sites" rather than
as a failure. `ast` sees every call whatever it looks like."""
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
found: set[tuple[str, str]] = set()
for py in routes_dir.rglob("*.py"):
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", None)
if name != "capped_body":
continue
limit = next(
(kw.value for kw in node.keywords if kw.arg == "limit"),
node.args[1] if len(node.args) > 1 else None,
)
found.add((py.name, ast.unparse(limit) if limit is not None else "<none>"))
assert found == CAPPED_BODY_SITES, (
f"a capped_body call site appeared, moved or changed shape: {found ^ CAPPED_BODY_SITES}. "
"Each one declares a body size; check it against bodylimit.DEFAULT_MAX_BODY_BYTES and, if "
"it is larger, add its route to bodylimit.ROUTE_MAX_BODY_BYTES — then update this list."
)
# And the two known ones really do satisfy the rule.
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
"""The upload route's in-handler cap is a `sysconfig` value an admin can change; the edge is a
constant. If the knob could be set above the edge, an admin raising it would silently start
getting 413s from a layer the admin page never mentions.
The relation is `<=`, not `==`, and the gap is the point: the edge counts RAW body bytes while
the route counts the DECODED file, so the edge has to allow for the multipart envelope or a file
of exactly the advertised maximum is refused one layer before the route that would take it.
"""
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
assert spec.max is not None and spec.max <= edge
assert edge - spec.max == bodylimit.MULTIPART_ENVELOPE_ALLOWANCE
def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db):
"""`set_value` refuses an out-of-range int, but neither bound has always been what it is now —
the upload knob had no `max` before SEC-20 and its `min` was 0 before E7 — so a value that was
legal when it was written can be sitting in the database. Served as-is it is exactly the drift
the test above forbids: the route would accept bytes the edge refuses.
The fallback is the env default and a warning, NOT a clamp. A clamp is a silent rewrite, and on
the min side it would be a wrong one: a stored 0 meant "no per-file cap" to the route, and
clamped to 1 it becomes a one-byte cap that kills uploads with nothing in the log.
"""
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
assert spec.max is not None and spec.min is not None
default = settings.download_upload_max_bytes
row = SystemConfig(key="download_upload_max_bytes", value=str(spec.max * 4))
db.add(row)
db.commit()
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
row.value = "0" # the legacy "no per-file cap", now below min
db.commit()
assert sysconfig.get_int(db, "download_upload_max_bytes") == default
# An override that IS in range must still be honoured — otherwise a bounds predicate written
# backwards would make every override inert and the two assertions above could not tell.
# Deliberately not `spec.max`: that equals the env default for this key, so the assertion would
# be satisfied by the fallback it is meant to rule out.
row.value = str(spec.max - 1)
db.commit()
assert spec.max - 1 != default
assert sysconfig.get_int(db, "download_upload_max_bytes") == spec.max - 1
def test_the_cap_sits_inside_the_response_decorators_and_outside_the_setup_gate():
"""The registration order, held in code rather than in the comment beside it. `add_middleware`
inverts the reading order (last registered = OUTERMOST), which is exactly the kind of thing a
prose description gets backwards — it already did once.
The layers are named, not counted by class: all four `@app.middleware("http")` decorators —
including the setup gate, which must be INSIDE — register as the same `BaseHTTPMiddleware`, so a
class-shaped assertion passes for every arrangement anyone would plausibly get wrong.
Outside the cap: only response decorators, so the 413 is dressed like any other answer. Inside
it: everything that does work on the caller's behalf — the setup gate opens a DB session per
request while an instance is unconfigured, which is the anonymous pre-auth work SEC-20 exists to
remove.
"""
from app.main import app
def layer(m) -> str:
dispatch = m.kwargs.get("dispatch") if m.kwargs else None
return dispatch.__name__ if dispatch is not None else getattr(m.cls, "__name__", str(m.cls))
names = [layer(m) for m in app.user_middleware] # outer → inner
i = names.index("BodyLimitMiddleware")
assert set(names[:i]) <= {
"log_requests",
"security_headers",
"static_cache_headers",
"CORSMiddleware",
}, f"something that is not a response decorator sits outside the cap: {names[:i]}"
assert "log_requests" in names[:i], "the access log must stay outside, so a 413 is logged"
assert "setup_gate" in names[i + 1 :], "the setup gate must not run for an oversized body"
assert "SessionMiddleware" in names[i + 1 :]
def test_the_screenshot_ceiling_matches_the_cap_the_route_itself_enforces():
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
assert (
bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/debug-sessions/{sid}/screenshots")]
== debugsession.SCREENSHOT_MAX_BYTES
)
# --- the real application -----------------------------------------------------------------------
def test_the_global_refusal_carries_the_baseline_security_headers():
"""The 413 is a response like any other, so it has to be dressed like one. The middleware sits
INSIDE the security-header and CORS layers for exactly this reason: a refusal a browser drops
for a missing header is indistinguishable from a network failure, and the user would be shown a
generic error instead of the translated `too_large`.
A bare TestClient on purpose, not the `client` fixture: this is the only test that exercises the
middleware ORDER, and that fixture is DB-gated — so on a host without Postgres the one assertion
holding the move would have skipped while the file still reported green. The refusal never
reaches a database; it is answered above the router.
"""
from app.main import app
r = TestClient(app).post(
"/auth/password-login", content=OVER, headers={"content-type": "text/plain"}
)
assert r.status_code == 413
assert r.headers.get("x-content-type-options") == "nosniff"
assert "referrer-policy" in r.headers
def test_the_login_route_is_refused_before_a_single_dependency_runs(client, monkeypatch):
"""`/auth/password-login` is the reason SEC-20 exists: it is anonymous, it declares a body, and
FastAPI reads that body in full before the first dependency. `_login_limiter.allow` is the very
first statement of the handler, so it is the sharpest available proof that nothing ran."""
from app.routes import auth as auth_routes
reached = False
def _record(*args, **kwargs):
nonlocal reached
reached = True
return True
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
r = client.post("/auth/password-login", content=OVER, headers={"content-type": "text/plain"})
assert r.status_code == 413
assert not reached, "the handler ran — the cap is inside the router instead of ahead of it"
def test_a_normal_login_body_still_reaches_the_handler(client, monkeypatch):
"""The mutation check for the test above. If the cap refused every body, the 413 test would
pass while the app was broken; this one would not."""
from app.routes import auth as auth_routes
reached = False
def _record(*args, **kwargs):
nonlocal reached
reached = True
return True
monkeypatch.setattr(auth_routes._login_limiter, "allow", _record)
r = client.post("/auth/password-login", json={"email": "nobody@example.com", "password": "x"})
assert r.status_code == 401
assert reached
def test_the_global_refusal_is_byte_identical_to_the_per_route_one(db, as_user, make_user):
"""Two shapes for one fact is how the `cancelled`/`canceled` bug happened. The log sink caps
itself with `capped_body`; its 413 and the middleware's must be the same bytes.
Signed in on purpose: `current_user` resolves before the body dependency, so an anonymous call
would be answered 401 and never reach the per-route cap this compares against. The body is over
the SINK's 512 KiB but under the global 1 MiB, so it is `capped_body` that refuses it.
"""
user = make_user()
db.commit()
over_the_sink = b"x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
assert len(over_the_sink) < DEFAULT_MAX_BODY_BYTES
r = as_user(user).post(
"/api/diagnostics/events",
content=over_the_sink,
headers={"content-type": "application/json"},
)
assert r.status_code == 413
# RAW BYTES, not parsed JSON: the two used to differ by the two spaces `json.dumps` puts in by
# default and `JSONResponse` does not, and a structural comparison could not see it — which is
# the only kind of drift the docblock's promise was ever at risk from.
assert r.content == bodylimit.TOO_LARGE_BODY
@@ -0,0 +1,66 @@
"""The Python major is a contract, not a coincidence — the backend half (E27 S3).
`0.93.2` was released to fix exactly this bug on the JavaScript side: four machines built or checked
the app and none of them agreed on which Node they ran, the server gate went red for 73 of its last
90 runs, and nothing told anyone. The Python side had the same shape and nobody had looked: prod
(`Dockerfile`), the test image (`Dockerfile.test`) and CI (`check.yml`) each spelled **3.12** as an
independent literal, there was no `.python-version`, no `requires-python`, and ruff had no
`target-version` — while the dev machine had no 3.12 installed at all, only 3.13 and 3.14.
**The contract is a chain, and it is split across two test files because no single runner can see
every link.** This suite runs inside the test image, which mounts `backend/` and nothing else, so it
cannot read the repo-root Dockerfile or the workflow. It therefore holds the link it CAN see, and the
one that matters most here:
the interpreter actually running == backend/pyrightconfig.json == backend/ruff.toml
and `frontend/src/lib/pythonEngine.contract.test.ts` — which runs on the host, with the whole tree —
holds the rest of the chain:
/.python-version == pyrightconfig.json == ruff.toml == Dockerfile == Dockerfile.test == check.yml
Together they close the loop. To move the version: edit `/.python-version`, then follow the failures
in both files. Every place they name is a place that would otherwise have been forgotten.
"""
import re
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
def _version_in(path: Path, pattern: str) -> tuple[int, int]:
"""The (major, minor) the given file declares. A rename or a moved line lands here — re-point
the test, the contract did not go away with it."""
text = path.read_text(encoding="utf-8")
match = re.search(pattern, text, re.MULTILINE)
assert match is not None, f"{pattern!r} not found in {path.name}"
return int(match.group(1)), int(match.group(2))
# pyrightconfig.json is JSONC (it carries comments), so it is read by regex rather than json.loads.
PINNED = _version_in(BACKEND / "pyrightconfig.json", r'"pythonVersion"\s*:\s*"(\d+)\.(\d+)"')
def test_pins_a_plausible_python_version():
"""Guards against a typo agreeing with nothing at all."""
assert PINNED >= (3, 12), PINNED
def test_this_interpreter_is_the_pinned_one():
"""The assertion that pays. A green suite on another minor does not mean the shipped image is
green — it means the suite and the image have drifted and nobody noticed yet."""
running = sys.version_info[:2]
assert running == PINNED, (
f"these tests are running on Python {running[0]}.{running[1]}, but the app is pinned to "
f"{PINNED[0]}.{PINNED[1]} (backend/pyrightconfig.json, /.python-version). The suite runs in "
f"the test image, so this failing means backend/Dockerfile.test has drifted from the pin."
)
def test_ruff_judges_the_pinned_version():
"""Unset, ruff falls back to a default that has nothing to do with us, and its syntax and
upgrade advice are then aimed at some other interpreter."""
major, minor = _version_in(BACKEND / "ruff.toml", r'^target-version\s*=\s*"py(\d)(\d+)"')
assert (major, minor) == PINNED
+22
View File
@@ -187,6 +187,28 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
// eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole
// React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were
// triaged rather than bulk-suppressed, because the two answers are different:
//
// react-hooks/refs 217 — OFF, see below.
// the other 66 — real. They went into eslint-suppressions.json as a ratchet for one
// sprint and E27 S2 burned the file down to nothing: what survives is
// an inline directive at the site, each naming the invariant it
// protects. There is DELIBERATELY no suppressions file any more — a
// bulk `--suppress-all` would hide the next violation instead of
// answering it, and it cannot hold a warning-level rule anyway.
//
// `refs` is off because it fires on this app's popover architecture, not on its history, so
// suppressing it would not help the next popover anyone writes. Measured over the 217:
// 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
// setters, which are the documented way to use it — and 74 more are passing the popover handle
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
// real (E27 S2 answered the ones the other rules could see, at their sites).
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/literal-a11y-text": "error",
+90 -64
View File
@@ -18,9 +18,9 @@
"hls.js": "^1.6.16",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
"react": "19.3.0",
"react-dom": "19.3.0",
"react-i18next": "^15.7.4"
},
"devDependencies": {
"@eslint/js": "9.39.1",
@@ -28,12 +28,13 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "^25.0.1",
@@ -2922,32 +2923,24 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.3.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -3469,6 +3462,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4084,16 +4087,23 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
@@ -4586,6 +4596,23 @@
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
@@ -4776,6 +4803,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5279,18 +5307,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -5824,42 +5840,39 @@
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.3.0"
}
},
"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": {
@@ -5867,6 +5880,9 @@
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
@@ -6016,13 +6032,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
@@ -6368,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",
@@ -6836,6 +6849,19 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}
}
}
+7 -6
View File
@@ -34,9 +34,9 @@
"hls.js": "^1.6.16",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2"
"react": "19.3.0",
"react-dom": "19.3.0",
"react-i18next": "^15.7.4"
},
"devDependencies": {
"@eslint/js": "9.39.1",
@@ -44,12 +44,13 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "^25.0.1",
+26
View File
@@ -0,0 +1,26 @@
import react from "@vitejs/plugin-react";
import reactCompiler from "babel-plugin-react-compiler";
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
//
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
// if the compiler ran only in the production build, every vitest run would be exercising code the
// user never receives, and a compiler-induced behaviour change would ship green. That is precisely
// the defect `0.93.2` was released to fix — a gate and a build image that had quietly stopped being
// the same thing — so the rule here is that the gate compiles the code the same way the build does.
//
// The React Compiler auto-memoises: it infers what `useCallback`/`useMemo` were being hand-written
// for and applies the equivalent itself. It is enabled repo-wide with no opt-out list because it
// bails out on its own for anything it cannot prove safe (its rules are also linted, via
// eslint-plugin-react-hooks). `target` follows the installed React major and must move with it.
//
// The plugin is IMPORTED rather than named by the usual `"babel-plugin-react-compiler"` string. Two
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
// while a bad import is a resolve error on the tsc lane.
export const reactWithCompiler = () =>
react({
babel: {
plugins: [[reactCompiler, { target: "19" }]],
},
});
+70 -32
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -131,6 +131,9 @@ export default function App() {
});
const needsSetup = setupQuery.data?.configured === false;
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
const me = meQuery.data;
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
@@ -141,32 +144,40 @@ export default function App() {
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
useGlobalKeymap(
!!meQuery.data,
setPage,
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A debug session that is
// recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
const diagnostics = meQuery.data?.diagnostics;
const debugSession = meQuery.data?.debug_session;
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
// applyDiagnostics computes an effective-level key and returns early when it has not moved
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
// before that early return, which is the half we WANT re-run. The old dep list picked out four
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
// job to verify, not a comment's to assert, and it silently missed any other field the module
// may come to read.
const diagnostics = me?.diagnostics;
const debugSession = me?.debug_session;
useEffect(() => {
applyDiagnostics(
diagnostics,
debugSession,
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
);
}, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps
}, [diagnostics, debugSession, queryClient]);
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
// nothing; only the idle callback is re-scheduled.
useEffect(() => {
const me = meQuery.data;
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
@@ -179,7 +190,7 @@ export default function App() {
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [me]);
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
@@ -197,9 +208,13 @@ export default function App() {
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
// on an account switch was never the point — by then the params are already gone and a second
// clearParams is a no-op.
const signedIn = !!me;
useEffect(() => {
if (meQuery.data) clearParams("access", "verify", "reset");
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (signedIn) clearParams("access", "verify", "reset");
}, [signedIn]);
// Any request that 401s means the session ended server-side. If we were signed in (cached
// `me` is a user object), reload to the login page at once (also covers any click that hits
@@ -218,12 +233,20 @@ export default function App() {
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
// Settings → Account so the new state is visible, then strip the param for a clean URL.
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
// toast when the user changes language. (The old `[]` got away with it only because clearParams
// had already removed the param — an invariant held by a side effect two lines further down.)
const linkAnswered = useRef(false);
const { refetch: refetchMe } = meQuery;
useEffect(() => {
if (linkAnswered.current) return;
const result = new URLSearchParams(window.location.search).get("link");
if (!result) return;
linkAnswered.current = true;
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void meQuery.refetch();
void refetchMe();
setAccountRaw(LS.settingsTab, "account");
setPage("settings");
} else {
@@ -231,42 +254,59 @@ export default function App() {
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
}
clearParams("link");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [t, setPage, refetchMe]);
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
// neutral toast is the right feedback — the pre-auth login banner would never render here.
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
const oauthAnswered = useRef(false);
useEffect(() => {
if (oauthAnswered.current) return;
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
oauthAnswered.current = true;
notify({ level: "info", message: t("settings.account.googleCancelled") });
clearParams("oauth");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [t]);
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
// `me` is known, and only for an admin (others just land on their default page).
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
// deep link was answered.
const adminDeepLinkAnswered = useRef(false);
useEffect(() => {
if (adminDeepLink !== "access-requests" || !meQuery.data) return;
if (meQuery.data.role === "admin") {
if (adminDeepLinkAnswered.current) return;
if (adminDeepLink !== "access-requests" || !me) return;
adminDeepLinkAnswered.current = true;
if (me.role === "admin") {
focusAccessRequestsTab();
setPage("users");
}
clearParams("admin");
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}, [adminDeepLink, me, setPage]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
// before — the notice stayed in the old language until the count changed.
const role = me?.role;
const pendingInvites = me?.pending_invites ?? 0;
const pendingActivations = me?.pending_activations ?? 0;
useEffect(() => {
if (meQuery.data?.role !== "admin") return;
if (role !== "admin") return;
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
removeByMetaKind("access-requests");
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
// address was approved before they registered. The second used to produce NO signal at all —
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
const pending = (meQuery.data.pending_invites ?? 0) + (meQuery.data.pending_activations ?? 0);
const pending = pendingInvites + pendingActivations;
if (pending > 0) {
notify({
level: "warning",
@@ -285,8 +325,7 @@ export default function App() {
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites, meQuery.data?.pending_activations]);
}, [role, pendingInvites, pendingActivations, t, setPage]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
@@ -302,7 +341,7 @@ export default function App() {
if (needsSetup) return <SetupWizard />;
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
if (!me) return <Welcome verify={verifyEntry} />;
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
@@ -323,16 +362,15 @@ export default function App() {
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
// side rail shows behind it.
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const activePage: Page = !pageGate || pageGate(me!) ? page : "feed";
const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<NavSidebar
me={meQuery.data!}
me={me!}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
@@ -368,7 +406,7 @@ export default function App() {
key={channelView.id}
channelId={channelView.id}
initialName={channelView.name}
me={meQuery.data!}
me={me!}
view={view}
setView={setView}
onShowInFeed={() => {
@@ -393,11 +431,11 @@ export default function App() {
<PageShell
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
banners={
<>
<VerifyBanner entry={verifyEntry} />
{meQuery.data!.is_demo && <DemoBanner />}
{me!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
</>
}
@@ -417,9 +455,9 @@ export default function App() {
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
<KeyboardHelp />
<AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
{me && !me.is_demo && <ChatDock meId={me.id} />}
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{meQuery.data && !meQuery.data.is_demo && <DebugRecorder />}
{me && !me.is_demo && <DebugRecorder />}
<Suspense fallback={null}>
{aboutOpen && (
<About
@@ -191,6 +191,26 @@ describe("BootGate", () => {
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
// rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this
// account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is
// installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which
// is exactly late enough to miss the re-run that setting `readyFor` triggers.
it("a plain boot never reloads, however many times the adoption effect re-runs", async () => {
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })));
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(reload).not.toHaveBeenCalled();
expect(mounts).toBe(1);
});
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
+19 -11
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useState, type ReactNode } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
@@ -83,23 +83,31 @@ export default function BootGate({ children }: { children: ReactNode }) {
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed.
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
// account must not re-run the adoption over a language the user has just changed. That rule used
// to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness —
// and the failure mode of getting it wrong was a page-reload loop, not a stale value.
const meRef = useRef(me);
meRef.current = me;
useLayoutEffect(() => {
if (identity === undefined) return;
if (readyFor !== undefined) {
// The tree was built for another identity — see the header. The pin stays as it is: the tree
// being torn down still runs its unmount saves before the page goes, and with the pin moved they
// would land under the new account's local keys. The reloaded page's first probe corrects it.
location.reload();
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
// before the page goes, and with the pin moved they would land under the new account's local
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
// instead of a reload.
if (readyFor !== identity) location.reload();
return;
}
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
const lang = me?.preferences?.language;
const account = meRef.current;
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
const lang = account?.preferences?.language;
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
setReadyFor(identity);
// `me` is read for the identity it produced; a background refetch of the same account must not
// re-run the adoption over a language the user has just changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
}, [identity, readyFor]);
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
+8 -1
View File
@@ -95,7 +95,13 @@ export default function ChannelPage({
intervalMs: channelDetailPollMs,
});
const ch = detail.data;
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
// A fresh channel/banner gets the retries back, in the render that changes it: as an effect the
// new banner was committed with the previous one's exhausted attempt count still in place.
const [bannerFor, setBannerFor] = useState(ch?.banner_url);
if (bannerFor !== ch?.banner_url) {
setBannerFor(ch?.banner_url);
setBannerAttempt(0);
}
// The poll above is also the ONLY signal that the running backfill has written videos, so it
// drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on
// the PATCH response, before the background job has stored anything).
@@ -137,6 +143,7 @@ export default function ChannelPage({
if (ch.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId;
// eslint-disable-next-line react-hooks/set-state-in-effect -- a one-off INGEST of an unsubscribed channel's uploads on first visit — a network side effect, not state. The autoTried ref keyed on the channel id is what makes it once per channel.
void runExplore();
}, [ch, channelId, me.is_demo, runExplore]);
+28 -13
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
@@ -117,18 +117,28 @@ function SuggestedChipsLane({
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = suggestions.length - visible;
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, suggestions.length - visible);
const [overflowOpen, setOverflowOpen] = useState(false);
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
// the lane overflows. Convergent — the write falsifies its own condition.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
return (
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
@@ -340,7 +350,12 @@ export function ChannelTagsControl({
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = attached.length - visible;
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, attached.length - visible);
const [editorOpen, setEditorOpen] = useState(false);
const editPop = useAnchoredPopover({
@@ -353,18 +368,18 @@ export function ChannelTagsControl({
hostsInput: true,
});
const [overflowOpen, setOverflowOpen] = useState(false);
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
// and the flag reset with it, or the card returns unasked on the next overflow.
const [overflowOpenRaw, setOverflowOpen] = useState(false);
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
const overflowOpen = overflowOpenRaw && hidden > 0;
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
// If tags change so nothing is collapsed anymore, the "+N" trigger unmounts — force the reveal-all
// popover's state closed so it can't linger open against a gone reference.
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
const pillClass =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
+16 -3
View File
@@ -86,6 +86,7 @@ export default function Channels() {
setLayout,
setSort,
focusChannel: onFocusChannel,
consumeFocus,
} = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
@@ -281,10 +282,22 @@ export default function Channels() {
},
];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
// The TOKEN is the intent (it advances even when the same channel is re-focused); the name is the
// payload, so it rides a ref. Naming it would re-seed the box on any render that changed it,
// overwriting whatever the user had since typed.
//
// `consumeFocus` is what stops this page's OWN remounts from re-applying it: this effect runs on
// every mount, and this page unmounts on every navigation away — and on opening a channel page,
// which does not even change `page`. The provider then reports `focusName` as null, so a later
// visit leaves the box exactly as the user left it.
const focusChannelNameRef = useRef(focusChannelName);
focusChannelNameRef.current = focusChannelName;
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusChannelToken]);
const name = focusChannelNameRef.current;
if (!name) return;
setSearch(name);
consumeFocus();
}, [focusChannelToken, setSearch, consumeFocus]);
// The header's reset intent clears the search box (skip the initial mount); the tag chips ride in
// `filters.tags`, which goToFullHistory already clears by setting a fresh map.
const resetRef = useRef(filtersResetToken);
@@ -8,8 +8,8 @@
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { renderHook, act, render as renderRoot } from "@testing-library/react";
import { useEffect, useRef, type ReactNode } from "react";
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "channels" }),
@@ -127,3 +127,85 @@ describe("ChannelsProvider — S8 shareable URL", () => {
});
});
});
// --- the focus-a-channel intent is consumed, not masked -------------------------------------------
// `focusChannel` sends the user to the manager with its name filter seeded. The manager is a page
// chunk: it unmounts on every navigation away, and on opening a channel page (which does not even
// change `page`), and its seeding effect runs again on each mount. So the "already applied" marker
// has to live in the PROVIDER — inside the page it would reset with the page and re-apply an intent
// from earlier in the session over whatever the user has typed into the search box since.
describe("ChannelsProvider — the focus-a-channel intent", () => {
/** Stands in for Channels.tsx:287-297 — the same effect, the same consume call. */
function Manager() {
const { focusName, focusToken } = useChannels();
const { setSearch, consumeFocus } = useChannelsActions();
const nameRef = useRef(focusName);
nameRef.current = focusName;
useEffect(() => {
const name = nameRef.current;
if (!name) return;
setSearch(name);
consumeFocus();
}, [focusToken, setSearch, consumeFocus]);
return null;
}
function Harness({ mounted }: { mounted: boolean }) {
return <>{mounted && <Manager />}</>;
}
it("seeds the search box once, and a later visit does not re-seed it", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
const { rerender } = renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
act(() => api.focusChannel("Kurzgesagt"));
expect(api.search).toBe("Kurzgesagt");
// The user looks for something else instead.
act(() => api.setSearch("veritasium"));
// Leave the manager (the page chunk unmounts) and come back.
rerender(
<ChannelsProvider>
<Probe />
<Harness mounted={false} />
</ChannelsProvider>,
);
rerender(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
expect(api.search).toBe("veritasium");
});
it("re-seeds when the channel is focused again — the token is the intent", () => {
let api!: ReturnType<typeof useChannels> & ReturnType<typeof useChannelsActions>;
function Probe() {
api = { ...useChannels(), ...useChannelsActions() };
return null;
}
renderRoot(
<ChannelsProvider>
<Probe />
<Harness mounted />
</ChannelsProvider>,
);
act(() => api.focusChannel("Kurzgesagt"));
act(() => api.setSearch("veritasium"));
act(() => api.focusChannel("Kurzgesagt")); // the same channel, a new intent
expect(api.search).toBe("Kurzgesagt");
});
});
+33 -9
View File
@@ -110,7 +110,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// it against the live columns for display, so the provider stores it un-healed (no options passed).
const [sort, setSort] = useSortState(LS.channelSort);
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusName, setFocusName] = useState<string | null>(null);
const [focusNameRaw, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
@@ -120,14 +120,18 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
// `view` is frozen at mount for the same reason `urlSeed` is: the comparison is against the tab
// this load started on, and the three setters are stable by construction (useFilterState,
// useSortState and useAccountPersistedState all return a useCallback). So the dep list can name
// every value the body reads without the seed re-arming on a later tab switch.
const [mountView] = useState(view);
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
if (urlSeed.view !== mountView) setViewRaw(urlSeed.view);
setFilters(urlSeed.filters);
setSort(urlSeed.sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [urlSeed, mountView, setViewRaw, setFilters, setSort]);
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
@@ -175,10 +179,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
setPage("channels");
}, [setFilters, setView, setPage]);
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
useEffect(() => {
if (page !== "channels") setFocusName(null);
}, [page]);
// The manager focus only means anything ON the Channels page, so it is DERIVED from the page
// rather than cleared by an effect when you leave. focusChannel sets the name and navigates in the
// same handler, so the value is live by the time channels renders; everywhere else it reads null,
// one render earlier than the effect used to make it.
//
// But an intent has to be CONSUMED, not merely masked, and the consumed marker has to live HERE.
// Channels is a page chunk that unmounts on every navigation (and on opening a channel page, which
// does not even change `page`), so its seeding effect runs again on each remount. A marker inside
// it would reset with it and re-apply an intent from earlier in the session over whatever the user
// has since typed into the search box. Keyed on the token rather than the name, because
// re-focusing the same channel is a new intent.
const [focusApplied, setFocusApplied] = useState(0);
const consumeFocus = useCallback(() => setFocusApplied(focusToken), [focusToken]);
const focusName = page === "channels" && focusApplied !== focusToken ? focusNameRaw : null;
// Label the entry the manager was REACHED on (setPage pushes a bare { sfPage }), so stepping back
// onto it later restores the tab it belonged to rather than leaving whatever tab is current.
@@ -211,9 +225,19 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
}),
[setSearch, setFilters, setView, setLayout, setSort, focusChannel, goToFullHistory],
[
setSearch,
setFilters,
setView,
setLayout,
setSort,
focusChannel,
consumeFocus,
goToFullHistory,
],
);
// S8: mirror the content state (tab + sort + column filters) into the address bar under the namespaced
+8 -4
View File
@@ -42,6 +42,10 @@ export default function ChatThread({
{ intervalMs: POLL_MS, enabled: !needsKey },
);
const items = q.data?.items ?? [];
// The two effects below take the FETCH as their trigger and read the payload through this ref, so
// their dep lists name everything they touch without making the identity of `q.data` a trigger.
const threadRef = useRef(q.data);
threadRef.current = q.data;
// Decrypt the thread when fresh server data arrives. Keyed on dataUpdatedAt (not `items`): the
// `?? []` fallback is a new array reference every render, which would otherwise re-run this
@@ -56,7 +60,7 @@ export default function ChatThread({
// hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass.
let pub: string | null = null;
let refreshed = false;
for (const m of q.data?.items ?? []) {
for (const m of threadRef.current?.items ?? []) {
if (!m.ciphertext || !m.iv) continue;
try {
if (!pub) pub = await partnerPub(partnerId);
@@ -84,7 +88,8 @@ export default function ChatThread({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
// `dataUpdatedAt` is the trigger and the payload is read through the ref above — see Messages,
// where keying this family on `q.data` itself produced a render loop.
}, [q.dataUpdatedAt, ready, isSystem, partnerId, t]);
// Opening the thread (or a new incoming message arriving) marks messages read server-side, so
@@ -101,13 +106,12 @@ export default function ChatThread({
lastPartner.current = partnerId;
lastIncoming.current = -1;
}
const incoming = (q.data?.items ?? []).filter((m) => m.recipient_id === meId).length;
const incoming = (threadRef.current?.items ?? []).filter((m) => m.recipient_id === meId).length;
if (incoming !== lastIncoming.current) {
lastIncoming.current = incoming;
qc.invalidateQueries({ queryKey: qk.conversations() });
qc.invalidateQueries({ queryKey: qk.messageUnread() });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, partnerId, meId, qc]);
// Scroll to the newest message when the count changes (open / new message) — NOT on `plain`,
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -59,10 +59,14 @@ export default function ConfigPanel() {
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
// The baseline is read through a ref, not named as a dependency: an unchanged refetch produces a
// new object, and re-seeding on that is exactly the clobbering of an in-progress draft that CB2
// was about. `dataVersion` (the config SHAPE) and the post-save token are the real triggers.
const baselineRef = useRef(baseline);
baselineRef.current = baseline;
useEffect(() => {
setDraft(baseline);
setDraft(baselineRef.current);
setSecretReset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => {
@@ -40,6 +40,7 @@ export default function DateFilterSelect({
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
// one stale "Custom range" frame between the external clear and this reset.
useLayoutEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the reveal is NOT derivable from the range — the user opens it on an empty range and fills it in afterwards. This undoes it only when the range is cleared from OUTSIDE while the popover is shut, and it is a layout effect so the trigger never paints a stale "Custom range".
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
@@ -108,6 +108,7 @@ afterEach(() => {
let composing: string | null = null;
function ComposerProbe() {
// eslint-disable-next-line react-hooks/globals -- a TEST probe: a module-level handle is how the test reads a hook's value from outside the tree without a wrapper that would change what is being measured.
composing = useComposerSession();
return null;
}
@@ -44,6 +44,7 @@ function useElapsed(since: string): string {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/purity -- the clock is READ at render and the interval above only re-renders — a `now` held in state is the moment the component mounted, which is the defect DiagnosticsSettings records from prod UAT.
return formatDuration(Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000)));
}
@@ -26,6 +26,7 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
+5 -5
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -152,10 +152,10 @@ export default function DownloadCenter() {
];
// Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session)
// — that would render a blank page with no active tab. Fall back to the default.
useEffect(() => {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, me.role]);
// Adjusted during render rather than from an effect: as an effect the blank page it exists to
// prevent was committed and painted first. `setTab` is this component's own state (persisted per
// account), so a render-phase write is allowed, and it converges — "queue" is always in the list.
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<a
+18 -6
View File
@@ -220,17 +220,21 @@ export default function Feed({
if (qEmpty && parseSort(filters.sort).key === "relevance") {
setFilters({ ...filters, sort: buildSort("date", "desc") });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qEmpty]);
// Naming `filters` is safe precisely because the write below falsifies the condition above: the
// sort it sets is "date", so the one re-run it causes does nothing. The old `[qEmpty]` was
// guarding against a loop that the guard already prevents.
}, [qEmpty, filters, setFilters]);
// Drop optimistic status overrides when filters change OR fresh server data
// arrives — after a refetch the server is authoritative, so a stale override
// (e.g. a video reverted to "new" from the notification center) won't keep it
// filtered out of the current view.
useEffect(() => {
const [overridesFor, setOverridesFor] = useState(filters);
if (overridesFor !== filters) {
setOverridesFor(filters);
setOverrides({});
setSavedOverrides({});
}, [filters]);
}
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
// Only a real refetch (page count doesn't grow) is authoritative.
@@ -284,7 +288,7 @@ export default function Feed({
message: v?.title
? i18n.t("feed.hiddenNamed", { title: v.title })
: i18n.t("feed.hidden"),
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
action: { label: i18n.t("feed.undo"), onClick: () => onStateRef.current(id, "new") },
meta: {
kind: "video-hidden",
videoId: id,
@@ -299,7 +303,10 @@ export default function Feed({
message: v?.title
? i18n.t("feed.markedWatchedNamed", { title: v.title })
: i18n.t("feed.markedWatched"),
action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
action: {
label: i18n.t("feed.unwatch"),
onClick: () => onStateRef.current(id, "new"),
},
meta: {
kind: "video-watched",
videoId: id,
@@ -328,6 +335,11 @@ export default function Feed({
},
[qc],
);
// The toast's Undo action re-enters onState, which is a forward reference to the binding still
// being defined — the same shape PlexPlayer's session reclaim has, and answered the same way.
const onStateRef = useRef(onState);
// eslint-disable-next-line react-hooks/immutability
onStateRef.current = onState;
const onResetState = useCallback(
(id: string) => {
@@ -67,10 +67,16 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload.
//
// The mount-time filters are FROZEN into their own state rather than left to an empty dep list:
// `filters` changes on every edit, so listing it honestly would re-persist — and setFilters
// setting the state it depends on would spin. Frozen, all three deps are stable and the effect
// means exactly what it says: write what the URL brought, once.
const [urlSeededFilters] = useState(filters);
useEffect(() => {
if (initialHadUrlFilters) setFilters(filters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// eslint-disable-next-line react-hooks/set-state-in-effect -- setFilters also WRITES to per-account localStorage, which is the point here — persisting what a share link brought. That is a side effect, not a value to derive, and the frozen seed above makes it happen exactly once.
if (initialHadUrlFilters) setFilters(urlSeededFilters);
}, [initialHadUrlFilters, urlSeededFilters, setFilters]);
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
+5 -1
View File
@@ -141,7 +141,10 @@ export default function GlassTuner() {
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
const enabled = usePrefs().theme.showTuner;
const { t } = useTranslation();
const initial = useMemo(loadSaved, []);
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
// when this mounted" is state — and the effect below applies them exactly once.
const [initial] = useState(loadSaved);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
ALL.forEach((s) => (base[s.k] = s.def));
@@ -167,6 +170,7 @@ export default function GlassTuner() {
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
setSwatches(sw);
}, [initial]);
+2
View File
@@ -474,6 +474,7 @@ export default function Home() {
hero: DiscoverPayload["hero"];
} | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- deliberately OUTLIVES the query: a failed reroll is terminal (retry:false) and must leave the previous answer on screen with the budget tag the SERVER echoed for it. Derived from pickQ it would vanish with the query.
if (pickQ.data !== undefined) setLastPick({ budget: pickQ.data.budget, hero: pickQ.data.hero });
}, [pickQ.data]);
@@ -695,6 +696,7 @@ export default function Home() {
// `reroll`'s changing identity causes — and to a token minted for another history entry.
const surpriseRequest = useSurpriseRequest();
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- consuming a cross-page token (lib/homeIntent) that was minted before this module's chunk existed. takeSurprise() clears it and answers false to every later pass, including StrictMode's second mount.
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
}, [surpriseRequest, reroll]);
@@ -603,9 +603,11 @@ function Attachments({
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
const pick = useRef<HTMLInputElement>(null);
const reviewButtons = useRef<Partial<Record<Source, HTMLButtonElement | null>>>({});
// eslint-disable-next-line react-hooks/immutability
useEffect(() => {
const source = reviewed.current;
if (!source) return;
// eslint-disable-next-line react-hooks/immutability -- a ONE-SHOT token, consumed: the parent sets it to say "give this source's review button the focus back when the list re-renders", and clearing it here is what stops the focus being stolen on every later render. The ref is the caller's, which is why it is a dependency rather than a local.
reviewed.current = null;
reviewButtons.current[source]?.focus();
}, [reviewed]);
+10 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
@@ -85,15 +85,20 @@ function ConversationList({
const items = q.data?.items ?? [];
const [previews, setPreviews] = useState<Record<number, string>>({});
// Decrypt conversation previews when fresh data arrives. Keyed on dataUpdatedAt (not `items`):
// the `?? []` fallback is a new array each render, which would otherwise spin this effect
// (and setPreviews) every render while the query is loading.
// Decrypt conversation previews when fresh data arrives. The TRIGGER is `dataUpdatedAt` and the
// payload is read through a ref, which is what makes the dep list below complete without making
// the identity of `q.data` a trigger. That distinction is load-bearing: the `?? []` fallback is a
// new array every render, and anything handing back a fresh object per render (a stub, a caller
// that rebuilds it) turns "re-run when the data changes" into a render loop through setPreviews.
// Measured the hard way — E27 S2 keyed this on `q.data`, and Messages.keyboard.dom.test hung.
const conversationsRef = useRef(q.data);
conversationsRef.current = q.data;
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
const out: Record<number, string> = {};
for (const c of q.data?.items ?? []) {
for (const c of conversationsRef.current?.items ?? []) {
const m = c.last_message;
if (m.kind === "user" && m.ciphertext && m.iv) {
try {
@@ -109,7 +114,6 @@ function ConversationList({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.dataUpdatedAt, ready]);
function previewText(c: Conversation): string {
+1
View File
@@ -33,6 +33,7 @@ function useLogEvents(): LogEvent[] {
setList(events());
});
});
// eslint-disable-next-line react-hooks/set-state-in-effect -- this IS the subscribe-to-an-external-store shape the rule's own text allows: the ring fills outside React, and a sync at subscribe time is what closes the gap between the first render and the listener. useSyncExternalStore would drop the rAF coalescing, which is the point at a few hundred events a second.
setList(events()); // whatever arrived between the first render and the subscription
return () => {
unsubscribe();
+17 -8
View File
@@ -153,10 +153,16 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
useEffect(() => setNavigator(setPage), [setPage]);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
useEffect(() => {
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran
// after the new page had already committed, so for one frame a module rendered with a live search
// still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has
// to be permanent — a derived null would quietly come back when you returned to the feed.
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) {
setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null);
}, [page]);
}
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
@@ -174,6 +180,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
document.title = label ? `${label} · ${brand}` : brand;
}, [signedIn, page, channelView, i18n.language, t]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
const [mountPage] = useState(page);
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
@@ -185,12 +195,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: page === "feed" ? null : page });
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "", url);
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "", url);
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
@@ -207,8 +217,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [mountPage]);
const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }),
+5 -3
View File
@@ -87,6 +87,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
meta: { errorShownInline: true },
});
const { mutate: startImport } = importSubs;
// First time we see read access with zero imported channels, pull the user's
// subscriptions automatically so they don't have to hunt for a button.
useEffect(() => {
@@ -97,10 +98,11 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
!importTriggered.current
) {
importTriggered.current = true;
importSubs.mutate();
startImport();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me.can_read, myStatus.data?.channels_total]);
// `mutate` is stable (react-query memoises it) while the mutation object around it is not, so
// the effect can name what it calls. The ref keeps it to once regardless.
}, [me.can_read, myStatus.data, startImport]);
// Closing without finishing the essential read step suppresses future auto-popups
// (it stays reachable from Settings). Once read is granted, closing is just "done".
+10 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
import {
@@ -107,13 +107,16 @@ export default function PersonalViewsModal() {
},
});
const resetDraft = filtersDraft.reset;
// Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
// value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
// view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
const selectedRef = useRef(selected);
selectedRef.current = selected;
useEffect(() => {
resetDraft(selected?.filters ?? {});
setNameDraft(selected?.name ?? "");
// Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its
// undo history. Save-current re-seeds explicitly in its own handler.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
const view = selectedRef.current;
resetDraft(view?.filters ?? {});
setNameDraft(view?.name ?? "");
}, [selectedId, resetDraft]);
// The selected view's captured filters, as the same human labels its module's chip row shows, read
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
@@ -351,3 +351,53 @@ describe("PlayerModal — the dialog contract", () => {
}
});
});
// --- the watch toggle belongs to the item it describes -------------------------------------------
// A late writer exists by construction: useYtProgress's cleanup for the OUTGOING item runs after the
// commit that already advanced `active`, and within the finish margin it auto-marks that item
// watched. While the mirror was a bare boolean that write landed last and painted the INCOMING item
// as watched. Keying the mirror by id makes the stray write not this item's, so it is ignored.
describe("PlayerModal — the watch toggle after a queue advance", () => {
const A = vid("A", 0);
const B = vid("B", 0);
beforeEach(() => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
video_id: id,
position_seconds: 0,
}));
});
it("does not inherit the outgoing item's auto-watch when stepping away near the end", async () => {
const onState = vi.fn();
clock.current = 2995; // inside min(FINISH_MARGIN, duration/2) = 10s of 3000
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
next();
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
await settle();
// A really is marked watched — that part is correct and must keep working.
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
// B is not, and its toggle says so. Before the fix both toggles read "watchedUnmark" here.
expect(screen.queryAllByTitle("player.watchedUnmark")).toHaveLength(0);
expect(screen.queryAllByTitle("player.markWatched").length).toBeGreaterThan(0);
});
it("still reflects a watch toggled on the item you are actually on", async () => {
const onState = vi.fn();
clock.current = 0;
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={onState} />,
);
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
fireEvent.click(screen.getAllByTitle("player.markWatched")[0]!);
expect(onState.mock.calls).toContainEqual(["A", "watched"]);
expect(screen.queryAllByTitle("player.watchedUnmark").length).toBeGreaterThan(0);
});
});
+103 -69
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useRef, useState } from "react";
import { useCallback, useEffect, useEffectEvent, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
@@ -170,7 +170,10 @@ export default function PlayerModal({
// The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
// modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus();
// Stable: it reads a ref and nothing else, and three hooks take it as a parameter (useYtVolume,
// useYtNativeControls, useYtBigView) where a fresh identity each render would spread into their
// dep lists too.
const focusModal = useCallback(() => cardRef.current?.focus(), []);
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
@@ -231,22 +234,51 @@ export default function PlayerModal({
setLiveData(null);
};
// Local mirror of watch status so the toggle reacts instantly; changes are
// propagated to the feed (and server) via onState.
const [status, setStatus] = useState(active.status);
const watched = status === "watched";
// Local mirror of watch status so the toggle reacts instantly; changes are propagated to the feed
// (and server) via onState. The mirror carries the id it DESCRIBES, and a write for any other id
// is ignored rather than displayed — that is the enforcement, and it has to be in code because
// one writer arrives late by construction: useYtProgress's cleanup for the OUTGOING item runs
// after the commit that already advanced `active`, and near the end of a clip it auto-marks that
// item watched (playerModalHooks.ts `maybeAutoWatch`). Keyed, that stray write is simply not this
// item's, so the toggle falls back to `active.status` and no ordering has to be argued about.
// Unkeyed it landed last and painted the NEXT video as watched; a corrective click then sent
// onState(next, "new") for an item that was already new.
const [status, setStatus] = useState<{ id: string; value: VideoStatus }>({
id: active.id,
value: active.status,
});
const watched = (status.id === active.id ? status.value : active.status) === "watched";
const setWatched = (on: boolean) => {
const next = on ? "watched" : "new";
setStatus(next);
setStatus({ id: active.id, value: next });
onState(active.id, next);
};
// On queue advance (active changes), sync local state to the new item.
useEffect(() => {
setStatus(active.status);
// On queue advance (active changes), point `currentVideoId` at the new item — DURING that render,
// not one commit later. As an effect, the frame between still had it on the previous id, so the
// "you have navigated away" affordance flashed on every advance. React discards this pass instead
// of committing it, so there is no such frame; the guard falsifies itself immediately.
//
// The watch status is NOT synced here: it is keyed by id above, which is both stale-proof and
// immune to the late writer this block cannot see (see that comment).
const [syncedTo, setSyncedTo] = useState(active.id);
if (syncedTo !== active.id) {
setSyncedTo(active.id);
setCurrentVideoId(active.id);
}
// `currentIdRef` must NOT move into the block above, and the reason is EFFECT ORDERING, which the
// committed test "resumes an item played earlier in the SAME session" measures. useYtProgress's
// per-item effect cleanup persists the outgoing item's position, and its persist() is guarded on
// `currentIdRef.current === id` so a video navigated to inside the player is never checkpointed
// against the library item. React runs every cleanup BEFORE any setup, so in this effect the ref
// still names the item being left while that cleanup runs, and flips to the new one just before
// useYtProgress (declared below) sets up for it. Advanced to render time the flip happens first,
// the outgoing checkpoint is skipped, and stepping next→prev back to an item resumes from the
// frozen launch queue's stale position — which is the exact bug the session-position map exists
// to prevent (748 instead of 830, in the test's numbers).
useEffect(() => {
currentIdRef.current = active.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
const seekTo = (seconds: number) => {
@@ -412,7 +444,11 @@ export default function PlayerModal({
// scope) so the help sheet lists them; only the behaviour behind each id is here.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
//
// The handler itself is a useEffectEvent (React 19.2+): it is bound ONCE per player, but it reads
// whatever is current at the moment a key arrives, which is what the disabled dep list used to
// claim in prose and now is by construction.
const onPlayerKey = useEffectEvent((e: KeyboardEvent) => {
// Volume by keyboard (YouTube/Plex-style ±5). The only volume control that survives fullscreen,
// where the wheel goes to YouTube's own iframe. But the modal card scrolls (a long description)
// and Up/Down is how you scroll it from the keyboard — so defer whenever the focused element can
@@ -432,59 +468,61 @@ export default function PlayerModal({
e.preventDefault();
nudgeSeek(secs);
};
const onKey = (e: KeyboardEvent) => {
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
// does not, so it must not switch the trap off.
if (e.key === "Tab") {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
ev.preventDefault();
setMaximized(false);
return;
}
onClose();
},
fullscreen: (ev) => {
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
// does not, so it must not switch the trap off.
if (e.key === "Tab") {
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
return;
}
dispatch(e, "ytPlayer", {
close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
if (escapeHandled(ev)) return;
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
if (maximizedRef.current) {
ev.preventDefault();
toggleFullscreen();
},
playPause: (ev) => {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
volumeUp: (ev) => volume(ev, true),
volumeDown: (ev) => volume(ev, false),
seekBack: (ev) => seek(ev, -5),
seekFwd: (ev) => seek(ev, 5),
prevVideo: (ev) => {
ev.preventDefault();
goPrev();
},
nextVideo: (ev) => {
ev.preventDefault();
goNext();
},
});
};
setMaximized(false);
return;
}
onClose();
},
fullscreen: (ev) => {
ev.preventDefault();
toggleFullscreen();
},
playPause: (ev) => {
// Let Space activate a focused button/link; otherwise toggle playback (and stop the
// page from scrolling).
if (isActivationTarget(ev)) return;
ev.preventDefault();
togglePlay();
},
volumeUp: (ev) => volume(ev, true),
volumeDown: (ev) => volume(ev, false),
seekBack: (ev) => seek(ev, -5),
seekFwd: (ev) => seek(ev, 5),
prevVideo: (ev) => {
ev.preventDefault();
goPrev();
},
nextVideo: (ev) => {
ev.preventDefault();
goNext();
},
});
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => onPlayerKey(e);
window.addEventListener("keydown", onKey);
focusModal();
// (The scroll lock is the shared, counted one in useDialogChrome.)
@@ -492,11 +530,7 @@ export default function PlayerModal({
window.removeEventListener("keydown", onKey);
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
};
// Bound once per player on purpose: every helper the handler calls reads its live state through
// a ref (playerRef, navRef, maximizedRef, isFullscreenRef, the volume hook's own ref), so
// re-binding on each render would only churn the listener, never change what a key does.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onClose]);
}, [focusModal]);
// Create the player, resume from the saved position, checkpoint progress, and auto-mark watched at
// the end — recreated per active item. The once-bound event closures read fresh queue/mode/volume via
+18 -7
View File
@@ -290,6 +290,9 @@ export default function Playlists() {
});
const detail = detailQuery.data;
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
// the object around them is rebuilt every render (lib/useUndoable).
const { reset: resetOrder, rebase: rebaseOrder } = order;
useEffect(() => {
if (!detail) return;
// Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
@@ -297,7 +300,7 @@ export default function Playlists() {
const key = `${selectedId}:${idsKey(detail.items)}`;
if (key !== lastSetRef.current) {
lastSetRef.current = key;
order.reset(detail.items);
resetOrder(detail.items);
} else {
// Same membership, fresher ROWS — a watch position the player just stored, a status. The
// guard above looks at the id set only, so without this the list kept showing, and handing the
@@ -305,17 +308,23 @@ export default function Playlists() {
// reopen it, and it resumed at the old spot and the next checkpoint wrote that over the 830.
// `rebase` takes the fresh rows into every snapshot and keeps the order and its undo history.
const fresh = new Map(detail.items.map((v) => [v.id, v]));
order.rebase((list) => list.map((v) => fresh.get(v.id) ?? v));
rebaseOrder((list) => list.map((v) => fresh.get(v.id) ?? v));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail]);
// `reset` and `rebase` are stable useCallbacks (lib/useUndoable), so naming them costs nothing;
// `selectedId` moves only together with `detail` anyway, since detail IS the selected playlist's.
}, [detail, selectedId, resetOrder, rebaseOrder]);
// Reset the view when switching playlists (the stored order itself is per-playlist).
useEffect(() => {
// Reset the view when switching playlists (the stored order itself is per-playlist) — during the
// render that switches, so the incoming playlist is never drawn once through the previous one's
// sort, filters and grouping. All three are this component's own state, which is what makes a
// render-phase write legal here.
const [viewFor, setViewFor] = useState(selectedId);
if (viewFor !== selectedId) {
setViewFor(selectedId);
setViewSort(STORED_SORT);
setFilters({});
setGroupBy(false);
}, [selectedId]);
}
const builtin = detail?.kind === "watch_later";
// YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
@@ -385,6 +394,7 @@ export default function Playlists() {
// Read by the drag announcements, which must report the CURRENT positions without making the
// announcement closures a render dependency of DndContext.
const shownRef = useRef(shown);
// eslint-disable-next-line react-hooks/immutability -- the house latest-value ref: the announcement closures must read the CURRENT list without the list becoming a render dependency of DndContext. A render-phase ref write is never part of the rendered output, which is what makes it safe; the checker is conservative about refs an effect also reads.
shownRef.current = shown;
const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
@@ -595,6 +605,7 @@ export default function Playlists() {
}
// Kept in a ref so the memoized column callbacks don't rebuild on every state change.
const removeRef = useRef(removeItem);
// eslint-disable-next-line react-hooks/immutability -- same latest-value ref as shownRef above — it keeps the memoised column callbacks from rebuilding on every state change.
removeRef.current = removeItem;
async function moveItemTo(video: Video, target: Playlist) {
+11 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -91,7 +91,9 @@ export default function PlaylistsRail({
queryFn: () => api.playlists(),
refetchOnWindowFocus: true,
});
const playlists = listQuery.data ?? [];
// Memoised so the effect and the sort below depend on a value that only changes with the query —
// `?? []` would otherwise mint a new empty array on every render while the list is loading.
const playlists = useMemo(() => listQuery.data ?? [], [listQuery.data]);
// Keep the selection valid: default to the first playlist, and if the selected one disappears
// drop to the next available (or clear when none remain). Waits for the first load.
@@ -104,10 +106,12 @@ export default function PlaylistsRail({
if (selectedId == null || !playlists.some((p) => p.id === selectedId)) {
setSelectedId(playlists[0]!.id); // playlists is non-empty (the length guard above returns)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, selectedId, listQuery.data]);
}, [playlists, selectedId, listQuery.data, setSelectedId]);
const plName = (p: { kind: string; name: string }) => playlistName(p, t);
// useCallback because the two memos below sort by it: as a fresh closure each render it could not
// be named in their dep lists, and `t` — which is what actually changes it, on a language switch —
// was therefore invisible to them. A rename in the other language now re-sorts.
const plName = useCallback((p: { kind: string; name: string }) => playlistName(p, t), [t]);
const sortedPlaylists = useMemo(() => {
const sign = plSort.dir === "asc" ? 1 : -1;
@@ -126,15 +130,13 @@ export default function PlaylistsRail({
return 0;
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playlists, plSort]);
}, [playlists, plSort, plName]);
const q = search.trim().toLowerCase();
const visiblePlaylists = useMemo(
() =>
q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists,
// eslint-disable-next-line react-hooks/exhaustive-deps
[sortedPlaylists, q],
[sortedPlaylists, q, plName],
);
useEffect(() => {
+25 -11
View File
@@ -112,14 +112,14 @@ export default function PlexBrowse() {
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
// Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. Keyed on the
// view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
// the bridge; clear it on unmount so the toggle disappears when you leave the plex page. `sub.open`
// is referentially stable (lib/history memoises it), so naming it costs nothing and the dep list is
// the honest one: the toggle is republished when the view KIND changes, and on nothing else.
const publishView = usePublishPlexView();
useLayoutEffect(() => {
publishView({ kind: sub.view.kind, open: sub.open });
return () => publishView(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sub.view.kind]);
}, [sub.view.kind, sub.open, publishView]);
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
@@ -128,8 +128,7 @@ export default function PlexBrowse() {
sub.open({ kind: "playlist", id: openPlaylist });
setPlaylistOpen(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
}, [openPlaylist, sub, setPlaylistOpen]);
// The header search typeahead picked a title → open it the same way a card click would (a movie's
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
@@ -150,8 +149,7 @@ export default function PlexBrowse() {
);
setJumpTo(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTo]);
}, [jumpTo, sub, setJumpTo]);
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
@@ -180,6 +178,17 @@ export default function PlexBrowse() {
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
// different title's info starts at the top.
const infoScrollRef = useRef(0);
// ONE writer for that memory, so "stash on the way out of an info page, reset on a fresh one" is a
// rule the code holds rather than one spread over four call sites — and so the compiler's
// complaint about it is answered once, here, instead of at each of them.
//
// The complaint is a false positive of a conservative analysis, not a hazard: the rule objects to a
// ref being written outside the effect that reads it, but that IS the arrangement a scroll memory
// needs — the navigation handlers record where the page was, and the restore layout effect above
// reads it on the way back. React's own guarantee (a ref write is never part of rendering) is what
// makes it safe, and nothing here reads the value during render.
// eslint-disable-next-line react-hooks/immutability
const rememberInfoScroll = (px: number) => void (infoScrollRef.current = px);
// One comfort-pin menu for the titles grid (see ui/ContextMenu).
const pinMenu = useContextMenu<PlexCard>();
const scroller = () => document.querySelector("main");
@@ -261,6 +270,11 @@ export default function PlexBrowse() {
// then a second after the clear; the query waits for both to settle into a single fetch.
useEffect(() => {
if (committedLang !== lang) {
// Not adjustable during render, which is where a state reset like this would otherwise
// belong: `setFilters` belongs to PlexProvider, and React only permits a render-phase setState
// on the component's OWN state. The cascade the rule warns about is bounded to one extra pass
// — the guard above is false immediately afterwards.
// eslint-disable-next-line react-hooks/set-state-in-effect
setCommittedLang(lang);
if (filters.genres.length) setFilters({ ...filters, genres: [] });
}
@@ -308,7 +322,7 @@ export default function PlexBrowse() {
}
function onInfo(card: PlexCard) {
scrollRef.current = scroller()?.scrollTop ?? 0;
infoScrollRef.current = 0; // fresh info page starts at the top
rememberInfoScroll(0); // fresh info page starts at the top
sub.open({ kind: "info", id: card.id });
}
// Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip).
@@ -316,7 +330,7 @@ export default function PlexBrowse() {
// it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a
// later info page would wrongly restore this stale offset.
function onOpenPerson(name: string) {
if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0;
if (sub.view.kind === "info") rememberInfoScroll(scroller()?.scrollTop ?? 0);
sub.open({ kind: "person", name });
}
// Optimistically flip a card's status in every cached library page — both the title grid (`items`)
@@ -361,7 +375,7 @@ export default function PlexBrowse() {
}
setFilters(merged as unknown as PlexFilters);
if (patch.actors?.length || patch.directors?.length) setScope("both");
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
rememberInfoScroll(scroller()?.scrollTop ?? 0); // restore on Back to the info/show page
sub.open({ kind: "grid" });
}
+38 -18
View File
@@ -43,6 +43,38 @@ const money = (n: number) =>
maximumFractionDigits: 1,
}).format(n);
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
// declared during render is a NEW component type on every render — React unmounts and remounts the
// subtree rather than updating it, which throws away DOM state and, for this one, the keyboard focus
// on the button while the info page re-renders around it.
function Score({
score,
filterable,
onFilterRating,
}: {
score: number;
filterable: boolean;
onFilterRating?: (score: number) => void;
}) {
const { t } = useTranslation();
return filterable && onFilterRating ? (
<button
onClick={() => onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:opacity-80"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</button>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</span>
);
}
// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience —
// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column
// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational.
@@ -65,29 +97,17 @@ function RatingsRow({
const primary = imdbRating ?? audience ?? null; // the filterable score
const chipCls =
"inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500";
const Score = ({ score, filterable }: { score: number; filterable: boolean }) =>
filterable && onFilterRating ? (
<button
onClick={() => onFilterRating(Math.floor(score))}
className="inline-flex items-center gap-1 hover:opacity-80"
title={t("plex.info.filterRating", { n: Math.floor(score) })}
>
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</button>
) : (
<span className="inline-flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current" />
{score.toFixed(1)}
</span>
);
const has = (x?: number | null): x is number => x != null;
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
{has(imdbRating) && (
<span className={chipCls}>
<Score score={imdbRating} filterable={imdbRating === primary} />
<Score
score={imdbRating}
filterable={imdbRating === primary}
onFilterRating={onFilterRating}
/>
<span className="text-2xs font-normal opacity-70">IMDb</span>
{imdbUrl && (
<a
@@ -111,7 +131,7 @@ function RatingsRow({
)}
{has(audience) && imdbRating !== audience && (
<span className={chipCls}>
<Score score={audience} filterable={!has(imdbRating)} />
<Score score={audience} filterable={!has(imdbRating)} onFilterRating={onFilterRating} />
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
</span>
)}
+35 -10
View File
@@ -208,6 +208,13 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Refs for values read inside stable callbacks / the keyboard handler (avoid stale closures).
const barAutoHideRef = useRef(prefs.barAutoHide);
barAutoHideRef.current = prefs.barAutoHide;
// The two language prefs are read only at ITEM LOAD (the detail effect below), so they go through
// a ref for the same reason `tRef` above does: naming them in that effect's deps would re-run it
// when the user picks a track from the player's own menu, double-loading the session the picker
// has already reloaded. The ref says "latest value, but not a trigger" in code, which an excused
// dep list only said in prose. Same shape as barAutoHideRef/seekStepRef either side of this.
const langPrefsRef = useRef({ audioLang: prefs.audioLang, subLang: prefs.subLang });
langPrefsRef.current = { audioLang: prefs.audioLang, subLang: prefs.subLang };
const seekStepRef = useRef(prefs.seekStep);
seekStepRef.current = prefs.seekStep;
const fineSeekStepRef = useRef(prefs.fineSeekStep);
@@ -474,7 +481,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
restartsRef.current.at = now;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
loadSession(playhead(absRef, loadStartRef), !video.paused);
// Through the ref, not by name: this is loadSession calling ITSELF from inside its own
// useCallback, which the checker rightly reads as "accessed before it is declared".
// The ref (assigned right below the callback) is the file's existing answer to that —
// detachSessionRef and cancelAutoSkipRef do the same for the same reason.
void loadSessionRef.current(playhead(absRef, loadStartRef), !video.paused);
return;
}
}
@@ -497,6 +508,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
},
[id], // NOT `t` — see tRef above (a language switch must not rebuild this / reload the session).
);
// The self-restart path above reaches the CURRENT loadSession through this, so the recursion is
// not a forward reference to a binding that is still being defined.
const loadSessionRef = useRef(loadSession);
// eslint-disable-next-line react-hooks/immutability -- the latest-value ref the self-restart path above reads; see the comment there for why the recursion cannot name loadSession directly.
loadSessionRef.current = loadSession;
// Audio/subtitle track selection + subtitle rendering. Owns the selected-ordinal state; the shared
// media refs stay component-owned (loadSession above + the detail effect below write audioRef too).
@@ -552,16 +568,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
restartsRef.current = { n: 0, at: 0 };
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);
const { audioLang, subLang } = langPrefsRef.current;
const aOrd = audioOrdForLang(detail.audio_streams ?? [], audioLang);
audioRef.current = aOrd;
setAudioOrd(aOrd);
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], prefs.subLang));
setSubOrd(subOrdForLang(detail.subtitle_streams ?? [], subLang));
const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
loadSession(resume);
// Prefs are applied at item load only; changing one via the picker must NOT re-run this (it would
// double-load the session — the picker handlers already reload/toggle).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detail, id, loadSession]);
void loadSession(resume);
}, [detail, id, loadSession, audioRef, setAudioOrd, setSubOrd]);
// Leaving the item: a session still in flight for it must not land after it, and its stream goes.
useEffect(
() => () => {
@@ -593,7 +607,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const v = videoRef.current;
return () => {
aliveRef.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps -- the live counter, as above
// The rule's advice — copy the ref into a variable and use the copy — is precisely wrong here.
// sessGenRef is a LIVE counter: bumping it is how the unmount tells every in-flight session
// that its response no longer belongs to anything, and a value captured when the effect ran
// would invalidate a generation that has since been superseded instead of the current one.
// eslint-disable-next-line react-hooks/exhaustive-deps
sessGenRef.current++;
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -839,7 +857,14 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
setUpNextOpen(false);
if (nextId) go(nextId);
}, [nextId, go]);
useEffect(() => setUpNextOpen(false), [id]);
// The "up next" card belongs to the item that was playing, so a queue advance closes it. Adjusted
// during the render that changes `id` rather than in an effect: as an effect it closed one commit
// after the new episode had already drawn behind it.
const [upNextForId, setUpNextForId] = useState(id);
if (upNextForId !== id) {
setUpNextForId(id);
setUpNextOpen(false);
}
// "Back" cascade shared by the on-screen Back arrow AND the keyboard/remote Backspace·Escape: peel
// off whatever is layered on top (a tuning/tracks menu, then the info overlay) before leaving the
@@ -12,7 +12,10 @@ type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
// only for the clock-date-style preview buttons.
export function PlexPlayerSettings(props: {
menuRef: RefObject<HTMLDivElement>;
// `| null` is not optional under React 19's types: `useRef<T>(null)` is now `RefObject<T | null>`,
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
// about that and 18's types let it.
menuRef: RefObject<HTMLDivElement | null>;
settingsTabs: { key: SettingsTab; label: string }[];
activeTab: SettingsTab;
setSettingsTab: (tab: SettingsTab) => void;
+8 -2
View File
@@ -48,8 +48,14 @@ export default function PlexSearchBox() {
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
// A fresh query resets the highlight.
useEffect(() => setHi(-1), [dq, activeField, scope]);
// A fresh query resets the highlight — during THAT render, so no frame ever draws the new
// suggestion list with the old row highlighted.
const suggestKey = `${scope}|${activeField}|${dq}`;
const [hiFor, setHiFor] = useState(suggestKey);
if (hiFor !== suggestKey) {
setHiFor(suggestKey);
setHi(-1);
}
// Keep the highlighted option scrolled into view during arrow-key navigation.
useEffect(() => {
if (hi < 0) return;
+20 -11
View File
@@ -129,11 +129,15 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
const [modalOpen, setModalOpen] = useState(false);
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
// and a feed modal shouldn't linger open on plex.
useEffect(() => {
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
// source rather than from an effect, so the first plex render never commits carrying the feed's
// sticky id — which is exactly long enough for a stale view chip to paint.
const [prevSource, setPrevSource] = useState(source);
if (prevSource !== source) {
setPrevSource(source);
setStickyId(null);
setModalOpen(false);
}, [source]);
}
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
@@ -146,17 +150,21 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
// `cfg` re-memoizes on every filter edit, so it is read through a ref: the seed must fire on the
// views query's first resolve for a source and never again, which `seededRef` enforces. Listing
// cfg would re-arm nothing (the guard still holds) but would re-run the effect on every keystroke
// in the filter bar. The ref keeps the dep list honest without that.
const cfgRef = useRef(cfg);
cfgRef.current = cfg;
useEffect(() => {
const data = viewsQuery.data;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
const c = cfgRef.current;
const def = pickDefaultToSeed(data, c.canSeedDefault());
if (!def) return;
cfg.setFilters(cfg.asFilters(def.filters));
c.setFilters(c.asFilters(def.filters));
setStickyId(def.id);
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewsQuery.data, source]);
const currentKey = cfg.keyOf(cfg.filters);
@@ -168,9 +176,10 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
),
[views, currentKey, stickyId, cfg],
);
useEffect(() => {
if (activeView) setStickyId(activeView.id);
}, [activeView]);
// The sticky id follows whichever view is active — also adjusted during render. It converges by
// construction: preferSticky returns the sticky match when there is one, so the very write below
// makes its own condition false on the next pass.
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
@@ -0,0 +1,232 @@
// @vitest-environment jsdom
//
// The scheduler's two number editors — a job's interval and the maintenance batch size. Both used to
// hold a MIRROR of the server value that an effect re-synced; both now show the server value and keep
// a draft only while the field is being edited (E27 S2). That matters here more than elsewhere
// because this page polls: every few seconds a fresh status arrives, and a mirror is exactly the
// thing a poll can overwrite at the wrong moment.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
});
import Scheduler from "./Scheduler";
import { api, type SchedulerStatus } from "../lib/api";
import { qk } from "../lib/queryKeys";
const status = (over: Partial<SchedulerStatus> = {}): SchedulerStatus => ({
running_here: true,
enabled: true,
paused: false,
jobs: [
{
id: "sync",
interval_minutes: 30,
next_run: null,
running: false,
status: "ok",
last_started: null,
last_finished: null,
last_result: null,
last_error: null,
progress: null,
},
],
queue: {
channels_recent_pending: 0,
channels_deep_pending: 0,
deep_eta_seconds: 0,
videos_pending_enrich: 0,
videos_pending_shorts: 0,
videos_live_refresh: 0,
},
quota: { used_today: 0, remaining_today: 100, daily_budget: 100, backfill_reserve: 0 },
maintenance: { revalidate_batch: 5000, revalidate_batch_default: 5000, min: 1000, max: 50000 },
...over,
});
let qc: QueryClient;
let current: SchedulerStatus;
async function mount(initial = status()) {
current = initial;
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<Scheduler />
</QueryClientProvider>,
);
await screen.findByText("scheduler.maintenance.batchLabel");
}
/** A poll landing, driven through the query the page actually makes. */
async function poll(next: SchedulerStatus) {
current = next;
await act(() => qc.refetchQueries({ queryKey: qk.scheduler() }));
}
beforeEach(() => {
vi.spyOn(api, "schedulerStatus").mockImplementation(() => Promise.resolve(current));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const intervalBox = () => screen.queryByRole("spinbutton", { name: "" });
const batchBox = () => screen.getByLabelText("scheduler.maintenance.batchLabel");
// The FIRST FOUR tests below are CHARACTERIZATION, not regression guards for E27 S2: the old
// JobRow's re-sync effect was already guarded by `if (!editing)` and the mirror only rendered while
// editing, so each of those four passes against the pre-sweep code too. They are here because the
// editor had no tests at all. The two after them — the range refusal and the focus return — ARE
// regression guards, for the silent-discard fix, and they fail against the code this file shipped
// with. The editor that genuinely lost the user's typing to a poll is the maintenance batch box
// below: its old effect had no guard, and that one has its own test.
describe("Scheduler · the job interval editor", () => {
it("opens on the current interval and commits a new one", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
const box = intervalBox()!;
expect(box).toHaveProperty("value", "30");
fireEvent.change(box, { target: { value: "45" } });
fireEvent.keyDown(box, { key: "Enter" });
expect(intervalBox()).toBeNull(); // committing closes the editor at once
await waitFor(() => expect(update).toHaveBeenCalledWith("sync", 45));
});
it("drops what you typed on Escape and shows the server's value again", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "999" } });
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
expect(update).not.toHaveBeenCalled();
expect(intervalBox()).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "30");
});
it("keeps what you are typing when a poll lands underneath", async () => {
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "45" } });
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 30, running: true }] }));
expect(intervalBox()).toHaveProperty("value", "45");
});
it("follows the server while the editor is closed", async () => {
await mount();
await poll(status({ jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }] }));
await waitFor(() =>
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
);
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
expect(intervalBox()).toHaveProperty("value", "90");
});
it("refuses a value outside the range instead of silently dropping it", async () => {
const update = vi.spyOn(api, "updateSchedulerJob").mockResolvedValue(undefined as never);
await mount();
fireEvent.click(screen.getByRole("button", { name: /scheduler.everyMin/ }));
fireEvent.change(intervalBox()!, { target: { value: "99999" } });
fireEvent.keyDown(intervalBox()!, { key: "Enter" });
expect(update).not.toHaveBeenCalled();
// Still open, still showing what was typed, and saying why.
expect(intervalBox()).toHaveProperty("value", "99999");
expect(intervalBox()!.getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
});
it("hands focus back to the trigger when the editor closes", async () => {
await mount();
const trigger = screen.getByRole("button", { name: /scheduler.everyMin/ });
fireEvent.click(trigger);
expect(document.activeElement).toBe(intervalBox());
fireEvent.keyDown(intervalBox()!, { key: "Escape" });
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: /scheduler.everyMin/ }),
),
);
});
});
describe("Scheduler · the maintenance batch editor", () => {
it("keeps what you are typing when a poll changes the batch underneath", async () => {
// THE regression this editor needed: its pre-E27 effect re-synced on every
// `revalidate_batch` change with no editing guard, so a poll landing mid-keystroke replaced
// what the admin had typed.
await mount();
fireEvent.change(batchBox(), { target: { value: "9000" } });
// The poll carries a new interval TOO, and the interval label follows the server unconditionally
// — so waiting for it is how this test knows the new status has actually been rendered. Without
// that anchor the assertion below passes on a page that simply has not polled yet.
await poll(
status({
jobs: [{ ...status().jobs[0]!, interval_minutes: 90 }],
maintenance: { ...status().maintenance, revalidate_batch: 12000 },
}),
);
await waitFor(() =>
expect(screen.getByRole("button", { name: /scheduler.everyMin/ })).toBeTruthy(),
);
expect(batchBox()).toHaveProperty("value", "9000");
});
it("refuses a value outside min..max instead of silently dropping it", async () => {
const save = vi.spyOn(api, "updateMaintenanceBatch").mockResolvedValue({
revalidate_batch: 5000,
});
await mount();
fireEvent.change(batchBox(), { target: { value: "10" } }); // below m.min = 1000
fireEvent.keyDown(batchBox(), { key: "Enter" });
expect(save).not.toHaveBeenCalled();
expect(batchBox()).toHaveProperty("value", "10");
expect(batchBox().getAttribute("aria-invalid")).toBe("true");
expect(screen.getByRole("alert").textContent).toBe("scheduler.range");
expect(screen.getByRole("button", { name: "common.save" })).toHaveProperty("disabled", true);
});
it("is not dirty until edited, and stops being dirty once saved", async () => {
const save = vi
.spyOn(api, "updateMaintenanceBatch")
.mockResolvedValue({ revalidate_batch: 9000 });
await mount();
const saveBtn = () => screen.getByRole("button", { name: "common.save" });
expect(saveBtn()).toHaveProperty("disabled", true);
fireEvent.change(batchBox(), { target: { value: "9000" } });
expect(saveBtn()).toHaveProperty("disabled", false);
fireEvent.keyDown(batchBox(), { key: "Enter" });
expect(saveBtn()).toHaveProperty("disabled", true); // the draft is dropped at once
await waitFor(() => expect(save).toHaveBeenCalledWith(9000));
});
it("follows a poll that changes the batch while nothing is being typed", async () => {
await mount();
expect(batchBox()).toHaveProperty("value", "5000");
await poll(status({ maintenance: { ...status().maintenance, revalidate_batch: 12000 } }));
await waitFor(() => expect(batchBox()).toHaveProperty("value", "12000"));
});
});
+74 -23
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import i18n from "../i18n";
@@ -139,17 +139,33 @@ function JobRow({
runDisabled: boolean;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(String(job.interval_minutes));
// The box shows the server's value and holds a draft only while it is being edited, so there is
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
// previous value for a frame.
const rangeId = useId();
const [draft, setDraft] = useState<string | null>(null);
const editing = draft !== null;
const val = draft ?? String(job.interval_minutes);
const next = secsUntil(job.next_run);
useEffect(() => {
if (!editing) setVal(String(job.interval_minutes));
}, [job.interval_minutes, editing]);
const m = parseInt(val, 10);
const valid = Number.isFinite(m) && m >= 1 && m <= 1440;
// Every way out of the editor unmounts the focused control, so hand focus back to the trigger that
// opened it — otherwise Escape drops a keyboard user onto <body> and the next Tab restarts at the
// top of the page. Same pattern as IssueComposer's review buttons.
const triggerRef = useRef<HTMLButtonElement | null>(null);
function close() {
setDraft(null);
queueMicrotask(() => triggerRef.current?.focus());
}
function save() {
const m = parseInt(val, 10);
if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m);
setEditing(false);
// A value outside the range is REFUSED, not swallowed: the draft stays so the user can see and
// correct what they typed, and `aria-invalid` + the hint say why. Dropping it silently left the
// field showing the server's number with no clue whether the save had happened.
if (!valid) return;
if (m !== job.interval_minutes) onSave(m);
close();
}
return (
@@ -170,34 +186,46 @@ function JobRow({
max={1440}
value={val}
autoFocus
onChange={(e) => setVal(e.target.value)}
aria-invalid={!valid}
aria-errormessage={valid ? undefined : rangeId}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") setEditing(false);
if (e.key === "Escape") close();
}}
className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
className={`w-16 bg-surface border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 ${
valid
? "border-border focus:ring-accent/40"
: "border-danger focus:ring-danger/40"
}`}
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<FocusSafeButton
onClick={save}
disabled={saving}
disabled={saving || !valid}
className="text-success hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
</FocusSafeButton>
<button
onClick={() => setEditing(false)}
onClick={close}
className="text-muted hover:text-fg"
aria-label={t("common.cancel")}
>
<X className="w-3.5 h-3.5" />
</button>
{!valid && (
<span id={rangeId} role="alert" className="text-2xs text-danger">
{t("scheduler.range", { min: 1, max: 1440 })}
</span>
)}
</span>
) : (
<Tooltip hint={t("scheduler.editInterval")}>
<button
onClick={() => setEditing(true)}
ref={triggerRef}
onClick={() => setDraft(String(job.interval_minutes))}
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
>
· {t("scheduler.everyMin", { count: job.interval_minutes })}
@@ -256,12 +284,23 @@ function MaintenanceSettings({
saving: boolean;
}) {
const { t } = useTranslation();
const [val, setVal] = useState(String(m.revalidate_batch));
useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]);
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
// the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
// `editing` guard at all.
const rangeId = useId();
const [draft, setDraft] = useState<string | null>(null);
const val = draft ?? String(m.revalidate_batch);
const n = parseInt(val, 10);
const valid = Number.isFinite(n) && n >= m.min && n <= m.max;
function save() {
const n = parseInt(val, 10);
if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n);
// Out of range is REFUSED, not swallowed: keep the draft so the typed value stays visible and
// correctable, and say why. Dropping it left the field showing the server's number with nothing
// to tell the user whether the save had happened, been rejected, or been ignored.
if (!valid) return;
if (n !== m.revalidate_batch) onSave(n);
// Dropping the draft is what lets the box follow the server again once the save lands.
setDraft(null);
}
const dirty = String(m.revalidate_batch) !== val;
@@ -281,15 +320,27 @@ function MaintenanceSettings({
max={m.max}
step={1000}
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") save();
if (e.key === "Escape") setDraft(null);
}}
aria-label={t("scheduler.maintenance.batchLabel")}
className="w-28 bg-surface border border-border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 focus:ring-accent/40"
aria-invalid={!valid}
aria-errormessage={valid ? undefined : rangeId}
className={`w-28 bg-surface border rounded-sm px-2 py-1 text-sm tabular-nums focus:outline-hidden focus:ring-2 ${
valid ? "border-border focus:ring-accent/40" : "border-danger focus:ring-danger/40"
}`}
/>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving}>
<Button variant="glass" size="sm" onClick={save} disabled={!dirty || saving || !valid}>
{t("common.save")}
</Button>
</div>
{!valid && (
<div id={rangeId} role="alert" className="text-2xs text-danger mt-1.5">
{t("scheduler.range", { min: m.min, max: m.max })}
</div>
)}
<div className="text-2xs text-muted mt-1.5">
{t("scheduler.maintenance.batchNote", {
default: formatNumber(m.revalidate_batch_default),
+6 -6
View File
@@ -20,17 +20,17 @@ export default function SetupWizard() {
const { t, i18n } = useTranslation();
const [token] = useState(() => new URLSearchParams(window.location.search).get("token") ?? "");
const [info, setInfo] = useState<{ secrets_manageable: boolean } | null>(null);
const [tokenError, setTokenError] = useState(false);
// "No token at all" is not a fetch outcome, it is a fact about the URL — so it is derived rather
// than pushed into state by an effect, which had to run and commit before the error could show.
const [infoFailed, setInfoFailed] = useState(false);
const tokenError = !token || infoFailed;
useEffect(() => {
if (!token) {
setTokenError(true);
return;
}
if (!token) return;
api
.setupInfo(token)
.then(setInfo)
.catch(() => setTokenError(true));
.catch(() => setInfoFailed(true));
}, [token]);
// Step state.
+7 -6
View File
@@ -299,15 +299,16 @@ function LinkShare({ job }: { job: DownloadJob }) {
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
// extra links, revoke) stay available on the created link and via the "New link" button.
const autoTried = useRef(false);
// The mutation OBJECT is rebuilt every render; `mutate` is not (react-query memoises it), so the
// two halves the effect actually uses can be named while `links.data` stays the only real trigger.
// The autoTried ref is what makes it fire exactly once.
const { mutate: createLink, isPending: createPending } = create;
useEffect(() => {
if (links.data && links.data.length === 0 && !autoTried.current && !create.isPending) {
if (links.data && links.data.length === 0 && !autoTried.current && !createPending) {
autoTried.current = true;
create.mutate();
createLink();
}
// Only links.data (empty → auto-create once) should drive this; `create` is a fresh object every
// render and would re-run the effect needlessly. The autoTried ref makes it fire exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [links.data]);
}, [links.data, createLink, createPending]);
return (
<div>
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// The chip-overflow popovers are open/closed by a DERIVED flag (`openRaw && hidden > 0`), which is
// what keeps a menu from outliving the "+N" trigger it is anchored to for a frame. The trap the
// derivation sets is that it hides the surface without closing it: floating-ui only reports a close
// for a real event on a mounted surface, so when the lane stops overflowing, the trigger and the
// menu disappear while the raw flag stays true — and the next re-fit that overflows again brings
// back a menu nobody asked for. `useChipOverflowFit` re-decides from a ResizeObserver, so that
// second re-fit needs no user action at all.
//
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
// measurement can only ever produce one answer.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.count != null ? `${k}:${o.count}` : k),
}),
};
});
const fit = { visible: 1 };
vi.mock("../lib/useChipOverflowFit", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/useChipOverflowFit")>();
return { ...actual, useChipOverflowFit: () => fit.visible };
});
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
const chip = (key: string): FilterChip => ({ key, label: key, active: false, onToggle: () => {} });
const chips = [chip("alpha"), chip("beta"), chip("gamma")];
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
const menu = () => screen.queryByRole("menu", { hidden: true });
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
afterEach(() => {
cleanup();
fit.visible = 1;
});
describe("TagFilterChips — the overflow menu", () => {
it("does not come back on its own once the lane stops overflowing", () => {
const { rerender } = render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
// The lane widens (a resize, the text-size slider, the trailing Clear button unmounting) and
// nothing is collapsed any more: both trigger and menu go.
fit.visible = 3;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toBeNull();
// …and it narrows again, with no user interaction in between. The menu must stay shut, and the
// returning trigger must not claim to be expanded.
fit.visible = 1;
rerender(<TagFilterChips chips={chips} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("does not come back when the chip set SHRINKS under the open menu", () => {
// The nastier half of the same bug, and it needs the hook's real timing to reproduce.
// `useChipOverflowFit` keeps `visible` in state and only rewrites it from a LAYOUT EFFECT, so
// the render in which the set shrinks still carries the measurement taken for the PREVIOUS,
// longer set. `length - visible` is negative in that render, a `hidden === 0` guard reading it
// unclamped never fires, and the flag survives the one window it exists to close.
//
// So `fit.visible` is left ALONE across the shrink — that staleness is the precondition, not a
// convenience of the mock. Five chips of which three fit, then three of them are deleted in the
// tag manager — so the shrink render computes `chips.length - visible` = 2 - 3 = -1.
const five = ["alpha", "beta", "gamma", "delta", "epsilon"].map(chip);
fit.visible = 3;
const { rerender } = render(<TagFilterChips chips={five} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
rerender(<TagFilterChips chips={five.slice(0, 2)} />); // `visible` is still the stale 3
expect(menu()).toBeNull();
// …and now the layout effect lands: two long names, only one of them fits.
fit.visible = 1;
rerender(<TagFilterChips chips={five.slice(0, 2)} />);
expect(menu()).toBeNull();
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
});
it("still opens and closes on the trigger", () => {
render(<TagFilterChips chips={chips} />);
fireEvent.click(moreBtn()!);
expect(menu()).toBeTruthy();
fireEvent.click(moreBtn()!);
expect(menu()).toBeNull();
});
});
+22 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
@@ -58,20 +58,33 @@ export default function TagFilterChips({
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = chips.length - visible;
const [open, setOpen] = useState(false);
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
// the flag survives exactly the window it exists to close, and the popover springs back on the
// next re-fit.
const hidden = Math.max(0, chips.length - visible);
// The popover can only be open while there is a "+N" trigger to anchor it. Derived rather than
// closed by an effect, so the frame in which the last overflowing chip fits again cannot also
// contain a menu hanging off a reference that is already gone.
//
// Deriving alone is NOT enough, and the missing half is the flag itself: floating-ui only reports
// a close for a real event on a mounted surface, so when the lane stops overflowing the trigger
// and the menu just disappear and `openRaw` stays true. The next re-fit that overflows again —
// a window resize, the text-size slider, or the trailing Clear button mounting as a tag is
// toggled; `useChipOverflowFit` re-decides from a ResizeObserver, with no user near this menu —
// would then re-open a menu nobody asked for, with its trigger already `aria-expanded`. So the
// flag is reset in RENDER against the same condition that hides it: convergent (the write
// falsifies it) and, unlike an effect, gone before the frame is painted.
const [openRaw, setOpen] = useState(false);
if (openRaw && hidden === 0) setOpen(false);
const open = openRaw && hidden > 0;
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
// linger against a gone reference.
useEffect(() => {
if (hidden === 0 && open) setOpen(false);
}, [hidden, open]);
const moreLabel = t("channels.tags.moreCount", { count: hidden });
return (
+4 -5
View File
@@ -138,8 +138,10 @@ const T = STR[lang];
export default function WatchPage() {
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
// to load, and "loading" was committed and painted before the effect said so.
const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone" | "error">(
"loading",
token ? "loading" : "gone",
);
const [meta, setMeta] = useState<Meta | null>(null);
const [password, setPassword] = useState("");
@@ -166,10 +168,7 @@ export default function WatchPage() {
// Bumped by Retry, which asks again.
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!token) {
setStatus("gone");
return;
}
if (!token) return; // the seed above already says "gone"
send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r) => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
+7 -2
View File
@@ -432,12 +432,17 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
// `done` and `error` belong to the view that produced them, and the render is `done ? msg : form`
// — without this, Back out of a submitted form would show the old success message under the
// previous view's title. Clearing on view change covers both Back/Forward and the mode links.
useEffect(() => {
// Cleared during the render that changes the view, not a commit later: the render is
// `done ? msg : form`, so an effect meant Back out of a submitted form drew the old success
// message under the new view's title for one frame — which is the exact symptom this guards.
const [outcomeFor, setOutcomeFor] = useState(mode);
if (outcomeFor !== mode) {
setOutcomeFor(mode);
setDone("");
setError("");
setBlocked(null);
setDeadReset(false);
}, [mode]);
}
// Explicit demo entry (replaces the old hidden probe): a labelled email field, still gated
// by the demo whitelist server-side.
@@ -39,6 +39,7 @@ export function WizardProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (meId == null || canRead == null) return;
const account = { can_read: canRead, is_demo: isDemo, google_enabled: googleEnabled };
// eslint-disable-next-line react-hooks/set-state-in-effect -- opening is a one-way decision taken from SERVER data as it arrives, not a value derivable in render — deriving `open` from the grants would re-open a wizard the user had just closed, which is exactly what the narrowed dep list above exists to prevent.
if (shouldAutoOpenOnboarding(account)) setOpen(true);
}, [meId, canRead, canWrite, isDemo, googleEnabled]);
@@ -33,6 +33,10 @@ export interface ChannelsActions {
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Mark the current focus intent as applied. The manager calls this the moment it seeds its name
// filter, so the intent cannot be re-applied when the page chunk remounts on a later visit and
// overwrite a search the user has typed since.
consumeFocus: () => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
@@ -57,6 +61,7 @@ export const ChannelsActionsContext = createContext<ChannelsActions>({
setLayout: () => {},
setSort: () => {},
focusChannel: () => {},
consumeFocus: () => {},
goToFullHistory: () => {},
});
+4 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
@@ -100,9 +100,9 @@ export function useComfortPin(open: boolean): ComfortPin {
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
// WHEN the first fetch happens, not whether the answer survives the menu closing.
const [everOpen, setEverOpen] = useState(false);
useEffect(() => {
if (open) setEverOpen(true);
}, [open]);
// Latched during render: the query below is gated on it, so an effect delayed the first fetch by
// a commit for no reason. Convergent — the write falsifies its own condition.
if (open && !everOpen) setEverOpen(true);
const shelf = useQuery({
queryKey: qk.comfortPins(),
+11 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, Copy, File, Link2, Scissors } from "lucide-react";
import clsx from "clsx";
@@ -77,8 +77,15 @@ function Thumb({ job }: { job: DownloadJob }) {
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
const primary = poster || remote;
// On load failure (a genuinely missing poster file) fall back to the remote source thumbnail.
const [src, setSrc] = useState(primary);
useEffect(() => setSrc(primary), [primary]);
// Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled
// down a long virtualized list, so a boolean-style mirror painted the previous job's fallback
// over the next one's poster for a frame. See ui/Avatar, which had the same defect.
// …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster
// that 404'd because its file was still being written should be re-requested when the row comes
// back, not written off for the life of the recycled slot. Convergent, so it belongs in render.
const [failedPrimary, setFailedPrimary] = useState<string | null>(null);
if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null);
const src = failedPrimary && failedPrimary === primary ? remote : primary;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") {
@@ -97,7 +104,7 @@ function Thumb({ job }: { job: DownloadJob }) {
loading="lazy"
className="w-full h-full object-cover"
onError={() => {
if (remote && src !== remote) setSrc(remote);
if (remote && src !== remote) setFailedPrimary(primary);
}}
/>
) : null}
@@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { useLayoutEffect } from "react";
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import Avatar from "./Avatar";
afterEach(cleanup);
// The failure state is keyed by URL rather than held as a boolean an effect resets, and the reason
// is a frame the user could see: Avatar is recycled across items in the virtualized feed, so after
// ONE image 429s (Google's avatar CDNs throttle bursts — see the component's own note), the next
// item to land in that slot rendered with its new `src` and the previous item's `failed`. The
// fallback initial was therefore committed and painted over a perfectly good image, and only the
// post-paint effect put it right.
//
// `useLayoutEffect` is what makes that observable instead of a race: layout effects run against the
// committed DOM BEFORE any passive effect, so the snapshot it takes is exactly the frame the browser
// would paint — the one the old reset effect had not repaired yet.
function Probe({ src, painted }: { src: string; painted: string[] }) {
useLayoutEffect(() => {
painted.push(document.body.innerHTML);
});
return <Avatar src={src} alt="avatar" fallback="Zed" />;
}
describe("Avatar · a failed image must not follow the component to the next item", () => {
it("shows the new src in the SAME commit that changes it, with no fallback frame between", () => {
const painted: string[] = [];
const { rerender } = render(<Probe src="https://cdn.test/a.jpg" painted={painted} />);
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
expect(screen.queryByRole("img")).toBeNull();
expect(screen.getByText("Z")).toBeTruthy();
painted.length = 0;
rerender(<Probe src="https://cdn.test/b.jpg" painted={painted} />);
// The FIRST frame after the src change already carries the image. Reading the last snapshot
// instead would let a passive-effect repair pass, which is precisely the bug.
expect(painted.length).toBeGreaterThan(0);
expect(painted[0]).toContain("https://cdn.test/b.jpg");
expect(painted[0]).not.toContain(">Z<");
});
// CHARACTERIZATION: the old boolean-plus-effect passes this too (the rerender is wrapped in act(),
// so its reset effect has run by the time the src is read). The frame-level guard is the test
// above, which the old shape fails; the retry guard is the test below, which it also fails.
it("keeps the fallback for the URL that actually failed, not for whatever is current", () => {
const { rerender } = render(
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
);
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
expect(screen.getByText("Z")).toBeTruthy();
rerender(<Avatar src="https://cdn.test/b.jpg" alt="avatar" fallback="Zed" />);
expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
"https://cdn.test/b.jpg",
);
});
it("retries a URL it comes back to — a 429 is transient, and so is the memory of it", () => {
// The instance is not remounted here on purpose: the player's channel avatar and the Messages
// thread header both swap `src` on a live instance. A memory that outlived the reason for it
// would pin a throttled channel to its initial for the rest of the session.
const { rerender } = render(
<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />,
);
fireEvent.error(screen.getByRole("img", { name: "avatar" }));
expect(screen.getByText("Z")).toBeTruthy();
rerender(<Avatar src="https://cdn.test/b.jpg" alt="avatar" fallback="Zed" />);
rerender(<Avatar src="https://cdn.test/a.jpg" alt="avatar" fallback="Zed" />);
expect(screen.getByRole("img", { name: "avatar" }).getAttribute("src")).toBe(
"https://cdn.test/a.jpg",
);
});
it("falls back when there is no src at all", () => {
render(<Avatar src={null} alt="avatar" fallback="Zed" />);
expect(screen.queryByRole("img")).toBeNull();
expect(screen.getByText("Z")).toBeTruthy();
});
});
+18 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
// Avatar image with a graceful fallback.
//
@@ -20,8 +20,22 @@ export default function Avatar({
fallback?: string; // a name/title; its first letter is shown when there's no image
className?: string;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [src]);
// The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes.
// That distinction is visible: this component is recycled across items in the virtualized feed, so
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
//
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
// swaps — the player's channel avatar stepping through a queue, the Messages thread header — would
// show the initial for a throttled channel forever, never re-requesting even after the CDN
// relented. Written in render because it is convergent (the write falsifies its own condition) and
// because an effect would leave one committed frame in which the memory is stale, which is the
// frame this whole component is about.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
const failed = !!src && failedSrc === src;
if (src && !failed) {
return (
@@ -30,7 +44,7 @@ export default function Avatar({
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
onError={() => setFailedSrc(src)}
className={className}
/>
);
+1
View File
@@ -36,6 +36,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
useEffect(() => {
const el = document.querySelector("main");
scrollerRef.current = el;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reading the DOM is the point — <main> is swapped by the page switch above us, and only after that commit can its scrollTop be read at all. Nothing derivable in render knows where the new page is scrolled to.
setShow(!!el && el.scrollTop > threshold);
}, [dep, threshold]);
@@ -246,6 +246,56 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
});
// 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");
fireEvent.focus(input);
fireEvent.change(input, { target: { value: "com" } });
expect(input).toHaveProperty("value", "com");
expect(screen.queryByText("Drama")).toBeNull(); // filtered away
fireEvent.keyDown(input, { key: "Escape" });
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input).toHaveProperty("value", "");
fireEvent.focus(input);
expect(screen.getByText("Drama")).toBeTruthy(); // the whole list is back
});
it("does not report a query change until something actually changes it", () => {
// A clear driven by an effect on `open` fires at MOUNT too — `open` starts false — so a
// controlled caller was told its own initial query had been cleared before the user touched
// anything. Driving it from the close event instead means no event, no callback.
const onQueryChange = vi.fn();
const onOpenChange = vi.fn();
wrap(
<Combobox
label="Collection"
multiple={false}
options={OPTIONS}
selected={[]}
onChange={vi.fn()}
query="com"
onQueryChange={onQueryChange}
onOpenChange={onOpenChange}
clientFilter={false}
/>,
);
expect(onQueryChange).not.toHaveBeenCalled();
fireEvent.focus(screen.getByRole("combobox"));
expect(onQueryChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Action"));
expect(onOpenChange).toHaveBeenLastCalledWith(false);
expect(onQueryChange.mock.calls).toEqual([[""]]);
});
});
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
+7 -6
View File
@@ -106,6 +106,13 @@ export default function Combobox({
const setOpenAnd = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
// Closing discards the typed query: the list is for "find and pick", and a leftover filter would
// hide everything worth seeing on the next open. It belongs HERE, not in an effect on `open`,
// because this is the single funnel every close goes through — floating-ui's dismiss, Escape and
// outside-click all arrive as onOpenChange, and the single-select commit calls it directly
// (verified: `setOpen` is called nowhere else). So the clear lands in the same commit as the
// close, and the controlled caller's onQueryChange fires once, from an event rather than a render.
if (!next) setQuery("");
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
// The input is the combobox reference for floating-ui AND our own focus target (the field's
@@ -127,12 +134,6 @@ export default function Combobox({
// would make Enter a no-op over a highlighted row.
const { activeIndex, highlightIndex } = pop;
// Clear the typed query when the list closes.
useEffect(() => {
if (!open) setQuery("");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const filtered = useMemo(() => {
if (!clientFilter) return options;
const q = query.trim().toLowerCase();
+10 -11
View File
@@ -81,6 +81,15 @@ export default function CommandPalette({
onClose: () => void;
groups: CommandGroup[];
}) {
// Closed means UNMOUNTED, not merely hidden, and that one line replaces the effect that used to
// blank the query and the picked row on every open: fresh state is what mounting already means.
// It also stops the ranking memo below — which folds and scores every channel, playlist and saved
// view — from re-running on every parent render while the palette is shut.
if (!open) return null;
return <CommandPaletteBody onClose={onClose} groups={groups} />;
}
function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups: CommandGroup[] }) {
const { t } = useTranslation();
const listId = useId();
const groupId = useId();
@@ -94,15 +103,6 @@ export default function CommandPalette({
const activeRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
// query from last time hides everything you might want now.
useEffect(() => {
if (open) {
setQuery("");
setPicked(null);
}
}, [open]);
// The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every
// time — and the rows are ranked inside each group. The best row overall starts highlighted, so
// Enter takes the best match even when it sits in a later group.
@@ -144,6 +144,7 @@ export default function CommandPalette({
const [announced, setAnnounced] = useState("");
useEffect(() => {
if (!summary) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the announcement is DEBOUNCED on purpose (ANNOUNCE_DELAY_MS above): a polite region rewritten per keystroke queues one utterance per letter. A timer's result cannot be derived in render, and the empty branch is the same timer's cancel.
setAnnounced("");
return;
}
@@ -151,8 +152,6 @@ export default function CommandPalette({
return () => window.clearTimeout(timer);
}, [summary]);
if (!open) return null;
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
+23 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager from "./Pager";
@@ -149,10 +149,19 @@ export default function DataTable<T>({
// `filters` as a dep (which would re-run them on every filter change).
const filtersRef = useRef(filters);
filtersRef.current = filters;
const commitFilters = (next: FilterMap) => {
if (controlledFilters) onFiltersChange!(next);
else setInternalFilters(next);
};
// …and the same trick for the CALLBACK, one line further: a controlled caller almost always
// passes an inline arrow, so `onFiltersChange` is a fresh function on every render. Reading it
// through a ref is what lets `commitFilters` be stable, and a stable commitFilters is what lets
// the two effects below NAME it in their dep lists instead of switching the check off.
const onFiltersChangeRef = useRef(onFiltersChange);
onFiltersChangeRef.current = onFiltersChange;
const commitFilters = useCallback(
(next: FilterMap) => {
if (controlledFilters) onFiltersChangeRef.current!(next);
else setInternalFilters(next);
},
[controlledFilters],
);
// Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled
// caller persists those itself, so writing them here would just leave a stale, never-read duplicate.
@@ -169,12 +178,16 @@ export default function DataTable<T>({
);
}, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]);
// Destructured so the deps are the values the body actually reads. `externalFilter` itself is an
// object literal at every call site, so depending on it would re-apply the filter — and reset the
// page — on every parent render.
const externalKey = externalFilter?.key;
const externalValue = externalFilter?.value;
useEffect(() => {
if (!externalFilter) return;
commitFilters({ ...filtersRef.current, [externalFilter.key]: externalFilter.value });
if (externalKey === undefined || externalValue === undefined) return;
commitFilters({ ...filtersRef.current, [externalKey]: externalValue });
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]);
}, [externalKey, externalValue, commitFilters]);
// Clear column filters when the reset token advances. The ref starts at 0 (not at the prop)
// so a navigation that bumped the token still clears even when it remounts this table fresh
@@ -186,8 +199,7 @@ export default function DataTable<T>({
resetRef.current = resetFiltersToken;
commitFilters({});
setPage(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetFiltersToken]);
}, [resetFiltersToken, commitFilters]);
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { useLayoutEffect } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
@@ -32,3 +33,80 @@ describe("Pager · rows per page", () => {
expect(onSizeChange.mock.calls).toEqual([[50], [0]]);
});
});
// The page box DERIVES the current page (E27 S2): it holds a draft only while being edited, so the
// parent moving the page needs no re-sync effect. The layout-phase probe is what proves "derived"
// rather than "re-synced after paint" — a passive effect could not have run yet when it reads.
describe("Pager · the page box follows the page without an effect", () => {
function Probe({ page, painted }: { page: number; painted: string[] }) {
useLayoutEffect(() => {
painted.push(
(document.querySelector('input[type="number"]') as HTMLInputElement | null)?.value ?? "",
);
});
return (
<Pager
page={page}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={() => {}}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>
);
}
it("shows the new page in the first frame after the parent moves it", () => {
const painted: string[] = [];
const { rerender } = render(<Probe page={0} painted={painted} />);
expect(screen.getByLabelText("datatable.pager.pageLabel")).toHaveProperty("value", "1");
painted.length = 0;
rerender(<Probe page={4} painted={painted} />);
expect(painted[0]).toBe("5");
});
it("keeps what you type until you commit it, then follows the page again", () => {
const onPageChange = vi.fn();
render(
<Pager
page={0}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "7" } });
expect(box).toHaveProperty("value", "7");
fireEvent.keyDown(box, { key: "Enter" });
expect(onPageChange).toHaveBeenCalledWith(6);
// The parent in this test never moves, so dropping the draft must put page 1 back — proof the
// box stopped holding its own copy rather than merely being overwritten.
expect(box).toHaveProperty("value", "1");
});
it("discards an unparseable entry instead of committing it", () => {
const onPageChange = vi.fn();
render(
<Pager
page={2}
size={10}
total={100}
pageSizeOptions={[10, 25]}
onPageChange={onPageChange}
onSizeChange={() => {}}
sizeLabel="Rows per page"
/>,
);
const box = screen.getByLabelText("datatable.pager.pageLabel");
fireEvent.change(box, { target: { value: "abc" } });
fireEvent.blur(box);
expect(onPageChange).not.toHaveBeenCalled();
expect(box).toHaveProperty("value", "3");
});
});
+12 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
@@ -34,9 +34,13 @@ export default function Pager({
const totalPages = pageCount(total, size);
const safePage = Math.min(page, totalPages - 1);
// Local text for the editable "jump to page" box (committed on Enter/blur).
const [pageInput, setPageInput] = useState(String(safePage + 1));
useEffect(() => setPageInput(String(safePage + 1)), [safePage]);
// Local text for the editable "jump to page" box (committed on Enter/blur). `null` means "not
// being edited", so the box simply DERIVES the current page instead of holding a copy that an
// effect has to re-sync after every page change — one render instead of two, and no window in
// which the box shows the page you just left. The draft is dropped on commit, which is the only
// place the two can disagree: clicking any other pager control blurs the input first.
const [draft, setDraft] = useState<string | null>(null);
const pageInput = draft ?? String(safePage + 1);
// Turning the page — or resizing it, which re-slices from a different first item — swaps the whole
// list, so put the reader at its top; landing mid-list in a page you haven't seen is disorienting.
@@ -51,8 +55,10 @@ export default function Pager({
}
function commitPageInput() {
const n = parseInt(pageInput, 10);
// Dropping the draft either way is what makes the box follow the real page again — on a valid
// jump because the page moves to it, on garbage because there is nothing to commit.
setDraft(null);
if (!isNaN(n)) goToPage(Math.min(totalPages - 1, Math.max(0, n - 1)));
else setPageInput(String(safePage + 1));
}
if (!hasPagerContent(total, size)) return null;
@@ -75,7 +81,7 @@ export default function Pager({
min={1}
max={totalPages}
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitPageInput}
onKeyDown={(e) => e.key === "Enter" && commitPageInput()}
aria-label={t("datatable.pager.pageLabel")}
+6
View File
@@ -89,10 +89,12 @@ export default function Toaster({
// AFTER the snapshot above has read the outgoing host's screen — this host's own first render as
// top would otherwise overwrite the handover with the toasts it is about to classify. Writing the
// same value twice is harmless; that is the point of the ref.
// eslint-disable-next-line react-hooks/globals -- the handover between toast hosts is module-level BY DESIGN and must be written during render — see the 12 lines above, which record why a useMemo produced the wrong answer under StrictMode. The write is idempotent, which is what makes a repeated render invocation safe.
if (isTop) lastTopIds = toasts.map((n) => n.id);
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!isTop) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- arming is a TIMER, not derived state: the live region must be mounted and empty for a tick before content lands in it or a screen reader never announces the first toast. Deriving `isTop && armed` would let a host that regains top skip that tick.
setArmed(false);
return;
}
@@ -196,6 +198,10 @@ export default function Toaster({
</div>
{isTop && Docked && (
<div className="pointer-events-auto w-max max-w-full">
{/* lib/toastDock is a one-slot registry: this identity changes only when a surface docks
or undocks, and remounting is then the correct behaviour, not the hazard the rule
warns about. */}
{/* eslint-disable-next-line react-hooks/static-components */}
<Docked />
</div>
)}
@@ -127,6 +127,15 @@ export default function VirtualGrid<T>({
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
// The React Compiler (E27 S1) SKIPS memoising this whole component, and says so: TanStack
// Virtual's `useVirtualizer()` hands back functions that cannot be memoised without going stale,
// so the compiler bails rather than risk a frozen viewport. That is the correct trade here — this
// component's cost is the row window it mounts, not its render closures — and it is the ONE such
// site in the app, which is why this is a targeted disable rather than the rule being turned off:
// a second incompatible library anywhere else must still stop the gate. The caution in the rule's
// own text applies, though: values taken from this API and handed to a memoised child can go
// stale, so keep the virtualizer's output inside this component.
// eslint-disable-next-line react-hooks/incompatible-library -- see above; @tanstack/react-virtual
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
@@ -143,6 +143,7 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
// menu opens; clear it whenever the surface closes.
useEffect(() => {
if (!opts.open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- both indices are also floating-ui's list-navigation state (passed to useListNavigation below), so they cannot be derived from `open` without lying to the library about where the cursor is. Clearing on close is what stops a stale row stealing the next open's initial focus.
setActiveIndex(null);
setHoverIndex(null);
}
@@ -61,6 +61,11 @@ export function useSortableA11y(describe: (id: UniqueIdentifier) => SortableSubj
},
onDragOver: ({ active, over }) => {
if (!over || (!moved && over.id === active.id)) return undefined;
// `moved` is per-DRAG state, and the announcement closures capturing it is the point:
// dnd-kit reports the item over ITSELF in the pick-up tick (see above), and this flag is
// the only thing separating that from a real move. A ref would be the wrong lifetime —
// one drag, not the component.
// eslint-disable-next-line react-hooks/immutability
moved = true;
return t("common.dnd.movedOver", landing(active.id, over.id));
},
@@ -20,6 +20,7 @@ export function usePlexPlaylistEdits(playlistId: number) {
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
const [items, setItems] = useState<PlexCard[]>([]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- the local copy is OPTIMISTIC — drag and remove write it before the server answers — so it cannot be derived from the query. This re-syncs it to the server's answer, and the rollback path below deliberately writes it too.
if (q.data) setItems(q.data.items);
}, [q.data]);
@@ -23,6 +23,7 @@
"quotaRemaining": "{{formatted}} left",
"quotaNote": "Shared YouTube Data API budget; resets at midnight US Pacific. Backfill yields to leave headroom for interactive use.",
"minutes": "min",
"range": "Must be between {{min}} and {{max}}.",
"editInterval": "Click to change how often this runs",
"intervalFailed": "Couldn't change the interval",
"runNow": "Run now",
@@ -23,6 +23,7 @@
"quotaRemaining": "{{formatted}} maradt",
"quotaNote": "Közös YouTube Data API-keret; éjfélkor (US Pacific) nullázódik. A backfill hátrébb sorol, hogy maradjon hely az interaktív használatnak.",
"minutes": "perc",
"range": "{{min}} és {{max}} között lehet.",
"editInterval": "Kattints a gyakoriság módosításához",
"intervalFailed": "Nem sikerült módosítani a gyakoriságot",
"runNow": "Futtatás most",
+15 -6
View File
@@ -9,7 +9,7 @@
// its own button pops exactly its entry without tripping the modals underneath.
//
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
import { urlWithParams } from "./urlState";
@@ -143,9 +143,14 @@ export function useHistorySubview<T>(
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return {
view,
open: (next: T, opts?: { replace?: boolean }) => {
// `open` and `back` are memoised so they are REFERENTIALLY stable, not merely behaviourally so.
// They used to be plain inline arrows: correct, but a fresh identity every render, which forced
// every consumer that listens for a sub-view change to disable the dependency check rather than
// re-run its effect on each parent render (three such effects in PlexBrowse alone). `url` is a
// module constant at every call site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only
// module functions and the setter, so there is nothing left to churn on.
const open = useCallback(
(next: T, opts?: { replace?: boolean }) => {
setView(next);
const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : "";
if (opts?.replace) {
@@ -165,8 +170,12 @@ export function useHistorySubview<T>(
);
notifyNav();
},
back: () => window.history.back(),
};
[url],
);
const back = useCallback(() => window.history.back(), []);
// Memoised as a whole for the same reason: a consumer that wants "re-run when the sub-view moves"
// can now write `[sub]` and mean it, instead of re-running on every parent render.
return useMemo(() => ({ view, open, back }), [view, open, back]);
}
// --- Overlays / modals (one history entry each, nesting-safe) -------------------------------
+47 -33
View File
@@ -1,5 +1,6 @@
import {
useEffect,
useEffectEvent,
useRef,
useState,
type Dispatch,
@@ -133,20 +134,20 @@ export function useYtVolume({
// Mouse-wheel volume over the WHOLE modal. Non-passive so preventDefault stops the modal/page
// scrolling. The cross-origin iframe swallows wheel over its native strips, but the central
// interaction overlay is our element (wheel there bubbles here), and everything outside works.
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
});
useEffect(() => {
const el = dialogRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
focusModal(); // re-grab focus if it had slipped into the native controls
nudgeVolume(e.deltaY < 0 ? 5 : -5);
};
const onWheel = (e: WheelEvent) => onWheelSpin(e);
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [dialogRef]);
// On unmount, clear the flash timer and flush any pending debounced write — closing the player right
// after a wheel spin (the normal way to leave) must not drop the level the user just set.
@@ -183,18 +184,19 @@ export function useNativeControlsYield({
setNativeControls: (on: boolean) => void;
} {
const [nativeControls, setNativeControls] = useState(false);
const handOverToIframe = useEffectEvent(() => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
if (f && document.activeElement === f) setNativeControls(true);
});
const rearmOurControls = useEffectEvent(() => {
setNativeControls(false);
focusModal();
});
useEffect(() => {
const iframeFocused = () => {
const p = playerRef.current;
const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
return !!f && document.activeElement === f;
};
// activeElement settles on the tick after blur, so defer the check.
const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
const rearm = () => {
setNativeControls(false);
focusModal();
};
const onBlur = () => window.setTimeout(() => handOverToIframe(), 0);
const rearm = () => rearmOurControls();
const stage = stageRef.current;
window.addEventListener("blur", onBlur);
window.addEventListener("focus", rearm);
@@ -204,8 +206,7 @@ export function useNativeControlsYield({
window.removeEventListener("focus", rearm);
stage?.removeEventListener("mouseleave", rearm);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [stageRef]);
return { nativeControls, setNativeControls };
}
@@ -264,19 +265,17 @@ export function useYtBigView({
// YouTube's own fullscreen — entered from its control bar, left by its button or Esc. On LEAVING,
// focus is stuck in the iframe (the user had to click into the player to reach YT's button), so our
// window-level shortcuts never fire — take focus back so F/Space/arrows work the moment fullscreen ends.
const syncFullscreen = useEffectEvent(() => {
const fsEl = fullscreenElement();
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
});
useEffect(() => {
const onChange = () => {
const fsEl = fullscreenElement();
// Refs only — this listener is bound once, so it must not close over render values.
const p = playerRef.current;
const frame = p && typeof p.getIframe === "function" ? p.getIframe() : null;
const ours = !!fsEl && (fsEl === stageRef.current || fsEl === frame);
isFullscreenRef.current = ours; // read by the once-bound keydown handler
setIsFullscreen(ours); // drives the overlay's pointer-events
if (!fsEl) focusModal();
};
return onFullscreenChange(onChange); // both spellings — see lib/fullscreen
// eslint-disable-next-line react-hooks/exhaustive-deps
return onFullscreenChange(() => syncFullscreen()); // both spellings — see lib/fullscreen
}, []);
return {
@@ -485,6 +484,21 @@ export function useYtProgress({
}
playerRef.current = null;
};
// ONE PLAYER PER VIDEO is the invariant, and it is why this dep list is deliberately just the
// id: re-running would destroy and rebuild the YouTube iframe, losing the playing position and
// every listener attached to it.
//
// This body DOES capture render values — `resumeAt`, `setWatched`, `advanceOnEnd`,
// `reapplyStored`, `focusModal`, `qc` — and it must, because each of them is a PER-ITEM
// SNAPSHOT: they describe the video this player was built for, and re-reading them mid-session
// would be the bug, not the fix. `resumeAt` is the clearest case, since persist() writes
// sessionPosRef and so changes it while `activeId` has not moved; the player is already playing
// from it and must not be told again. What genuinely has to stay live mid-session is behind refs
// (currentIdRef, modeRef, navRef, sessionPosRef, autoMarkedRef, errorStreakRef, playerRef) — and
// THAT split is the enforcement: a value that is not a snapshot does not belong in this closure.
//
// useEffectEvent cannot help here the way it does above: YT.Player takes its callbacks as an
// object literal, and an effect event may not be passed around as a value.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeId]);
}
@@ -330,6 +330,7 @@ function TrackHarness() {
absRef: { current: 3600 },
loadStartRef: { current: 0 },
});
// eslint-disable-next-line react-hooks/immutability -- a TEST probe: the hook's return has to reach the assertions, and a render-phase ref write is the only way to read it from outside without a wrapper that would change what is being measured.
harness.current = tracks;
return (
<video>
+10 -4
View File
@@ -426,8 +426,14 @@ export function useAutoSkip(opts: {
setSkipProgress(null);
}, []);
cancelAutoSkipRef.current = cancelAutoSkip;
// The marker is identified by type+offset rather than by object identity: `markers?.find` returns
// the same element while the playhead stays inside it, but a refetch of the markers list would
// hand back an equal-but-new object and restart a countdown already under way. The marker itself
// is then read from the ref above, so the dep list names every reactive input it has.
const activeMarkerType = activeMarker?.type;
const activeMarkerStart = activeMarker?.start_s;
useEffect(() => {
const m = activeMarker;
const m = activeMarkerRef.current;
if (!m) return setSkipProgress(null);
const key = `${m.type}-${m.start_s}`;
const enabled = m.type === "intro" ? autoSkipIntro : autoSkipCredits;
@@ -453,14 +459,14 @@ export function useAutoSkip(opts: {
window.clearInterval(iv);
skipTimerRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeMarker?.type,
activeMarker?.start_s,
activeMarkerType,
activeMarkerStart,
autoSkipIntro,
autoSkipCredits,
autoSkipDelay,
doSkip,
videoRef,
]);
return { activeMarker, skipProgress, skipProgressRef, cancelAutoSkipRef, doSkip };
@@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
// The Python major is a contract, not a coincidence — the host half. The twin of
// `nodeEngine.contract.test.ts`, and written the day after that one was: `0.93.2` shipped because
// four machines disagreed about Node, and the Python side turned out to have the identical shape
// with nobody watching. Prod (`Dockerfile`), the test image (`backend/Dockerfile.test`) and CI
// (`check.yml`) each spelled 3.12 as its own literal, there was no `.python-version`, and the dev
// machine had only 3.13 and 3.14 installed — so every ad-hoc host script ran two majors ahead of
// production.
//
// It lives in the FRONTEND suite for one boring reason: this is the only runner that can see the
// whole repository. The backend suite runs inside the test image, which mounts `backend/` alone, so
// it cannot read the repo-root Dockerfile or the workflow. It holds the other half of the chain —
// including "the interpreter running these tests IS the pinned one", which only it can assert:
//
// backend/tests/test_python_engine_contract.py : running interpreter == pyrightconfig == ruff.toml
// here : .python-version == pyrightconfig == ruff.toml
// == Dockerfile == Dockerfile.test == check.yml
//
// To move the version: edit `.python-version`, then follow the failures in both files.
const readRoot = (rel: string) => readFileSync(new URL(`../../../${rel}`, import.meta.url), "utf8");
function versionIn(source: string, pattern: RegExp, where: string): string {
const m = pattern.exec(source);
// A rename or a moved line lands here. Re-point the test — the contract did not go away with it.
expect(m, `${pattern} not found in ${where}`).not.toBeNull();
return `${m![1]}.${m![2]}`;
}
const pinned = versionIn(readRoot(".python-version"), /^(\d+)\.(\d+)\s*$/m, ".python-version");
it("pins a plausible Python version", () => {
// Guards against a typo'd `.python-version` silently agreeing with nothing.
expect(Number(pinned.split(".")[1])).toBeGreaterThanOrEqual(12);
expect(pinned.split(".")[0]).toBe("3");
});
it("runs the shipped backend on the pinned version", () => {
// The image our users' instance actually runs.
expect(versionIn(readRoot("Dockerfile"), /^FROM python:(\d+)\.(\d+)-slim/m, "Dockerfile")).toBe(
pinned,
);
});
it("tests on the pinned version", () => {
// The image the whole pytest suite runs in — including the backend half of this contract, which
// asserts its own `sys.version_info` against the pin.
expect(
versionIn(
readRoot("backend/Dockerfile.test"),
/^FROM python:(\d+)\.(\d+)-slim/m,
"Dockerfile.test",
),
).toBe(pinned);
});
it("runs the server gate on the pinned version", () => {
// The backend job's container. Its NODE is deliberately outside the Node contract (it only runs
// the checkout action), but its PYTHON runs our code and our tests, so it is inside this one.
expect(
versionIn(
readRoot(".gitea/workflows/check.yml"),
/^\s*container: nikolaik\/python-nodejs:python(\d+)\.(\d+)-nodejs\d+$/m,
"check.yml",
),
).toBe(pinned);
});
it("type-checks against the pinned version", () => {
// A type checker aimed at another version green-lights APIs the shipped interpreter does not have
// — the same defect as the image split, moved to check time.
expect(
versionIn(
readRoot("backend/pyrightconfig.json"),
/"pythonVersion"\s*:\s*"(\d+)\.(\d+)"/,
"pyrightconfig.json",
),
).toBe(pinned);
});
it("lints against the pinned version", () => {
expect(
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);
});
+17
View File
@@ -14,6 +14,23 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.94.0",
date: "2026-09-22",
summary:
"No new features, on purpose: a release spent entirely on the machinery underneath, so that whole classes of fault stop being possible rather than being fixed one at a time.",
fixes: [
"Typing an impossible number into a schedule - 0 minutes, 5000 minutes - used to close the editor and quietly keep the old value, as if it had been saved. The editor now says what the allowed range is, refuses to save until the number is inside it, and hands the keyboard back to the button you opened it from when you leave with Escape.",
],
chores: [
"Every request is now measured against a size limit before anything else reads it - before a password is checked, before an address is even resolved. Until now a stranger could make the app swallow a 32 MB upload and only then be told the password was wrong. Each address carries its own ceiling, including the ones that legitimately receive a film.",
"The server's Python is type-checked by the release gate for the first time: about 36 000 lines carried type annotations that nothing had ever verified. The mistakes already there are written down so they can be worked off release by release, and a new one now fails the build.",
"The app moved to React 19 and its compiler. The compiler now does, automatically and correctly, the bookkeeping that was written by hand in about 150 places - and hand-written bookkeeping is where most of this year's stale-value and stale-click bugs came from.",
"The 45 places where the old code told that same checker to stop looking at a piece of work are gone. Each was either a real bug, work that should never have been in that part of the code, or a genuine exception that now states the rule it depends on in code rather than in a comment.",
"The engine versions the app is built and shipped with are now pinned and checked on both sides, the way the JavaScript side was fixed in the previous release.",
"Four adversarial review rounds closed the work: 22 findings, then 13, then 6, then 2 - and the last two were in the review's own tests, not in the app. Everything found was fixed here, and every regression test was proven to fail with its own fix taken out.",
],
},
{
version: "0.93.2",
date: "2026-09-20",
@@ -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
});
});
+7 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARD_PAGE_SIZES, parseCardSize } from "./channelLayout";
import { readAccount, writeAccount } from "./storage";
@@ -18,12 +18,14 @@ export function useCardPager<T>(rows: T[], sizeKey: string, enabled: boolean) {
const totalPages = size <= 0 ? 1 : Math.max(1, Math.ceil(rows.length / size));
const safePage = Math.min(page, totalPages - 1);
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: the clamp
// A list that shrank under you (an unsubscribe, a refetch) leaves `page` out of range: `safePage`
// above only HIDES that, so commit it or it springs back the moment the list grows again. Only
// fires when the page you are on stopped existing, so it never costs you your place otherwise.
useEffect(() => {
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
}, [enabled, page, totalPages]);
//
// Adjusted DURING RENDER, not from an effect: React re-runs this component immediately and throws
// the first pass away, so nothing is committed with an out-of-range page and there is no second
// paint. The condition is what makes that safe — the very write that triggers it also falsifies it.
if (enabled && page > totalPages - 1) setPage(totalPages - 1);
const paged = !enabled || size <= 0 ? rows : rows.slice(safePage * size, safePage * size + size);
+3 -2
View File
@@ -99,8 +99,9 @@ export function useChipOverflowFit<R>(args: {
const ro = new ResizeObserver(run);
ro.observe(row);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deps]);
// The three refs are stable by construction, so naming them costs nothing and `deps` stays the
// only thing that actually re-measures.
}, [deps, rowRef, chipsRef, pillRef]);
return result;
}
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment jsdom
//
// The outside-press half of useDismiss. The hook subscribes its `mousedown` listener once per
// open/close, which is what keeps a popover from re-subscribing on every parent render — but it
// means the listener must not CAPTURE the caller's handler or the ref LIST, or it keeps using the
// versions that existed when the popover opened. Both are read through refs (E27 S2); these tests
// are what says so.
import { useRef, useState } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { useDismiss } from "./useDismiss";
afterEach(cleanup);
/** A panel that stays open while the caller swaps the handler it wants called. */
function SwappableHandler({ first, second }: { first: () => void; second: () => void }) {
const panel = useRef<HTMLDivElement>(null);
const [useSecond, setUseSecond] = useState(false);
useDismiss(true, useSecond ? second : first, panel);
return (
<div>
<div ref={panel}>panel</div>
<button onClick={() => setUseSecond(true)}>swap</button>
<div data-testid="outside">outside</div>
</div>
);
}
/** A panel that starts out registering one element and adds a second while it is open. */
function GrowingRefs({ onClose }: { onClose: () => void }) {
const panel = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [withTrigger, setWithTrigger] = useState(false);
useDismiss(true, onClose, withTrigger ? [panel, trigger] : [panel]);
return (
<div>
<div ref={panel}>panel</div>
<button onClick={() => setWithTrigger(true)}>register trigger</button>
<button ref={trigger}>trigger</button>
<div data-testid="outside">outside</div>
</div>
);
}
describe("useDismiss · outside press", () => {
it("closes on a press outside and stays put on a press inside", () => {
const onClose = vi.fn();
render(<SwappableHandler first={onClose} second={vi.fn()} />);
fireEvent.mouseDown(screen.getByText("panel"));
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls the handler the caller has NOW, not the one it had when the popover opened", () => {
const first = vi.fn();
const second = vi.fn();
render(<SwappableHandler first={first} second={second} />);
fireEvent.click(screen.getByText("swap"));
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(second).toHaveBeenCalledTimes(1);
expect(first).not.toHaveBeenCalled();
});
it("honours a ref the caller started passing after the popover opened", () => {
const onClose = vi.fn();
render(<GrowingRefs onClose={onClose} />);
fireEvent.mouseDown(screen.getByText("trigger"));
expect(onClose).toHaveBeenCalledTimes(1); // not registered yet — outside
onClose.mockClear();
fireEvent.click(screen.getByText("register trigger"));
fireEvent.mouseDown(screen.getByText("trigger"));
expect(onClose).not.toHaveBeenCalled(); // now it is one of ours
fireEvent.mouseDown(screen.getByTestId("outside"));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+16 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, type RefObject } from "react";
import { useEffect, useRef, type RefObject } from "react";
import { useLayer } from "./layerStack";
/** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given
@@ -26,19 +26,29 @@ export function useDismiss(
// Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown
// that lands outside every registered element.
// Both arguments are read through refs so the listener can stay subscribed once per open/close —
// which is the point of the `[active]` dep list — without going STALE. The disabled dep list this
// replaces asserted that onClose was "read fresh from this run's closure"; it was not. The closure
// is built when `active` last flipped, so a caller that re-rendered with a different handler while
// the popover stayed open kept getting the old one called (regression test in useDismiss.dom.test).
// `refs` is the weaker case — the ref OBJECTS are stable, so a late-mounting node is seen either
// way — but a caller that changes WHICH refs it passes while open was equally ignored.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const refsRef = useRef(refs);
refsRef.current = refs;
useEffect(() => {
if (!active) return;
const list = Array.isArray(refs) ? refs : [refs];
const onDown = (e: MouseEvent) => {
const current = refsRef.current;
const list = Array.isArray(current) ? current : [current];
const target = e.target as Node;
if (!list.some((r) => r.current?.contains(target))) onClose();
if (!list.some((r) => r.current?.contains(target))) onCloseRef.current();
};
document.addEventListener("mousedown", onDown);
return () => {
document.removeEventListener("mousedown", onDown);
};
// Matches the original effect: (re)subscribe only when open/closed toggles. Refs are stable and
// onClose is read fresh from this run's closure.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}
@@ -34,6 +34,7 @@ function Page() {
const ref = useRef<HTMLElement>(null);
useFocusKeeper(ref);
const [view, setView] = useState<"grid" | "detail">("grid");
// eslint-disable-next-line react-hooks/globals -- a TEST probe: the test drives the view switch from outside the tree, because useFocusKeeper's whole subject is what happens to focus across that switch — triggering it through a button would put focus on the button first and measure nothing.
show = setView;
return <main ref={ref}>{view === "grid" ? <Grid /> : <button>Back to library</button>}</main>;
}
+6 -6
View File
@@ -83,16 +83,16 @@ export function usePref<T>(
return p ? p(raw) : (raw as T);
}, []);
const seed = useCallback((): T => {
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
// place in this file that had to be excused from the checker (it read `defaultValue` without
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
const [value, setValue] = useState<T>(() => {
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
if (server !== undefined) return norm(server);
if (localKey) return norm(readAccount(localKey, defaultValue));
return defaultValue;
// defaultValue / serverKey / localKey are stable for a given call site.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qc, serverKey, localKey, norm]);
const [value, setValue] = useState<T>(seed);
});
const saveTimer = useRef<number | undefined>(undefined);
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
+7 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { urlWithParams } from "./urlState";
// Mirror a set of nav params onto the CURRENT history entry (replaceState — mirroring is not a new
@@ -10,8 +10,12 @@ export function useUrlMirror(updates: Record<string, string | null>): void {
// The caller typically passes an inline object literal (new identity every render); key on its
// serialized value so the effect only fires when a param actually changes, not every render.
const dep = JSON.stringify(updates);
// The object itself is read through a ref, so the effect's one reactive input is the serialized
// value — which is exactly what the dep list now says. The house pattern (see useChipOverflowFit's
// decideRef, usePref's parseRef): a ref for the identity that churns, a primitive for the trigger.
const updatesRef = useRef(updates);
updatesRef.current = updates;
useEffect(() => {
window.history.replaceState(window.history.state, "", urlWithParams(updates));
// eslint-disable-next-line react-hooks/exhaustive-deps
window.history.replaceState(window.history.state, "", urlWithParams(updatesRef.current));
}, [dep]);
}
+10 -1
View File
@@ -30,5 +30,14 @@
"allowJs": true,
"checkJs": true
},
"include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.js", "postcss.config.js"]
// reactPlugin.ts is the ONE React transform both configs import (E27 S1) — it is build tooling,
// not app source, so it belongs here and not under tsconfig.json's ["src"]. A file imported by a
// listed one but absent from `include` is TS6307, not a silent pass.
"include": [
"vite.config.ts",
"vitest.config.ts",
"reactPlugin.ts",
"tailwind.config.js",
"postcss.config.js"
]
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { reactWithCompiler } from "./reactPlugin";
// During `vite dev` (host with Node), proxy API calls to the backend container.
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
@@ -20,7 +20,7 @@ const proxy = {
};
export default defineConfig({
plugins: [react()],
plugins: [reactWithCompiler()],
server: { port: 5173, proxy },
build: { outDir: "dist" },
});

Some files were not shown because too many files have changed in this diff Show More