Compare commits

..
Author SHA1 Message Date
TudorandClaude Opus 5 59265f78b6 docs(flags): Unleash does not create flags by itself
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 35s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 35s
The runbook said a flag 'appears in the Unleash UI after the backend has
evaluated it once'. That is wrong. SDKs read definitions from the server
and never register anything, and metrics for an unknown flag are
discarded — so a declared flag is evaluated on every request, stays
False forever, and never shows up until someone creates it by hand.

Found the way these things usually are: staging had been running the
flag code for a while and the UI was still empty.

Also names the environment trap while here — each stack's token is
scoped to one environment, so toggling the other does nothing visible
and looks like the flag is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
2026-08-26 20:11:45 +01:00
20 changed files with 26 additions and 2452 deletions

No files matched your search

+2 -64
View File
@@ -33,7 +33,6 @@ from .data_loader import (
get_supplementary_data,
get_supplementary_data_batch,
search_schools_typesense,
suggest_schools_typesense,
)
from .data_loader import get_data_info as get_db_info
from . import flags
@@ -297,36 +296,8 @@ def clean_filter_values(series: pd.Series) -> list[str]:
# SECURITY MIDDLEWARE & HELPERS
# =============================================================================
def client_key(request: Request) -> str:
"""The rate-limit bucket: the real caller, not the proxy in front of them.
`get_remote_address` reads request.client.host. In staging and production
the backend has no published ports and sits on the internal network, so its
only caller is the Next proxy — meaning every browser user on the site
shared one bucket. Measured before this fix: 70 concurrent requests to
/api/schools returned 60 OK and 10 refused.
CF-Connecting-IP first, because Cloudflare (in front of both environments)
sets it on every origin request and *overwrites* any client-supplied value,
which a parsed X-Forwarded-For chain does not guarantee. The XFF fallback is
forgeable, but only by a caller already inside the Docker network, which is
the one place nothing untrusted can reach.
"""
cf = request.headers.get("cf-connecting-ip")
if cf:
return cf.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return get_remote_address(request)
# Rate limiter. No in-app global ceiling: slowapi's default_limits and
# application_limits are both keyed by key_func (so per-client, not global)
# and the latter only applies with SlowAPIMiddleware installed, which this app
# does not use. A global cap belongs at Cloudflare, which is already in the
# path. See the spec's §1 for why that is deliberate.
limiter = Limiter(key_func=client_key)
# Rate limiter
limiter = Limiter(key_func=get_remote_address)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
@@ -384,7 +355,6 @@ CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
("/api/rankings", (60, 600, 3600)),
("/api/compare", (60, 600, 3600)),
("/api/suggest", (60, 3600, 86400)), # autosuggest
("/api/schools", (30, 300, 1800)), # search list
]
@@ -1301,38 +1271,6 @@ async def get_place(request: Request, kind: str, slug: str,
}
# Two characters. One is not a query — it matches thousands of schools and the
# response is useless, so it is not worth a round trip.
SUGGEST_MIN_QUERY = 2
@app.get("/api/suggest")
@limiter.limit("120/minute")
async def suggest_schools(
request: Request,
q: str = Query("", max_length=100),
limit: int = Query(8, ge=1, le=20),
):
"""School name suggestions, from Typesense alone.
Deliberately not a mode of /api/schools: that path filters and sorts the
full in-memory DataFrame, which is far too expensive to run per keystroke.
Nothing here returns an error for ordinary input. A short query, no
matches, or Typesense being unreachable are all 200 with an empty list —
a dropdown that quietly does not appear is the right failure for a
keystroke path, and there is no DataFrame fallback because the 25,000-row
substring scan is precisely what this endpoint exists to avoid.
120/minute rather than the default 60: a 200 ms debounce makes typing
legitimately bursty.
"""
query = q.strip()
if len(query) < SUGGEST_MIN_QUERY:
return {"suggestions": []}
return {"suggestions": suggest_schools_typesense(query, limit)}
@app.get("/api/flags")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_feature_flags(request: Request):
-40
View File
@@ -100,46 +100,6 @@ def search_schools_typesense(query: str, limit: int = 250) -> List[int]:
return []
# The most a public endpoint will return in one response.
SUGGEST_MAX_LIMIT = 20
# Fields a suggestion row carries, and the default when the document omits an
# optional one. phase and school_type are optional in the Typesense schema.
_SUGGEST_FIELDS = ("school_name", "local_authority", "postcode",
"phase", "school_type")
def suggest_schools_typesense(query: str, limit: int = 8) -> List[dict]:
"""Autosuggest rows straight from Typesense. Never raises.
Returns documents rather than URNs, unlike search_schools_typesense, so the
caller needs no DataFrame. Every field below is already in the index — see
pipeline/scripts/sync_typesense.py — which is what makes this cheap enough
to run per keystroke.
"""
client = _get_typesense_client()
if client is None:
return []
try:
result = client.collections["schools"].documents.search({
"q": query,
"query_by": "school_name,local_authority",
"per_page": max(1, min(limit, SUGGEST_MAX_LIMIT)),
"typo_tokens_threshold": 1,
})
except Exception:
# A dropdown that quietly stops appearing is the right failure here.
return []
rows = []
for hit in result.get("hits", []):
doc = hit.get("document", {})
row = {"urn": int(doc.get("urn", 0))}
row.update({f: str(doc.get(f, "") or "") for f in _SUGGEST_FIELDS})
rows.append(row)
return rows
def normalize_school_type(school_type: Optional[str]) -> Optional[str]:
"""Convert cryptic school type codes to user-friendly names."""
if not school_type:
-7
View File
@@ -50,13 +50,6 @@ REGISTRY: dict[str, Flag] = {
),
added=date(2026, 8, 23),
),
Flag(
name="school_autosuggest",
description=(
"School name suggestions as you type in the main search box."
),
added=date(2026, 8, 26),
),
)
}
-58
View File
@@ -1,58 +0,0 @@
"""The rate-limit bucket must be the caller, not the proxy in front of them.
`get_remote_address` reads request.client.host. In staging and production the
backend has no published ports and its only caller is the Next proxy, so that
host is the Next container — one bucket for every browser user on the site.
Measured before this fix: 70 concurrent requests, 60 served and 10 refused.
"""
from starlette.datastructures import Headers
from backend.app import client_key
class _Req:
"""Enough of a Request for the key function: headers and a client host."""
def __init__(self, headers: dict, host: str = "10.0.0.9"):
self.headers = Headers(headers)
self.client = type("C", (), {"host": host})()
self.scope = {"type": "http", "client": (host, 0),
"headers": [(k.lower().encode(), v.encode())
for k, v in headers.items()]}
def test_cloudflare_header_wins():
# Cloudflare sets CF-Connecting-IP and overwrites any client-supplied
# value, so it is trustworthy in a way a parsed XFF chain is not.
assert client_key(_Req({"cf-connecting-ip": "203.0.113.7"})) == "203.0.113.7"
def test_forwarded_for_is_the_fallback_and_takes_the_first_entry():
# Left-most is the original client; everything after it is proxies.
assert client_key(
_Req({"x-forwarded-for": "203.0.113.7, 10.0.0.2"})) == "203.0.113.7"
def test_remote_address_is_the_last_resort():
assert client_key(_Req({}, host="10.0.0.9")) == "10.0.0.9"
def test_cloudflare_header_beats_forwarded_for():
key = client_key(_Req({"cf-connecting-ip": "203.0.113.7",
"x-forwarded-for": "198.51.100.1"}))
assert key == "203.0.113.7"
def test_two_callers_get_two_buckets():
# The whole point: one user exhausting their limit must not refuse another.
a = client_key(_Req({"cf-connecting-ip": "203.0.113.7"}))
b = client_key(_Req({"cf-connecting-ip": "203.0.113.8"}))
assert a != b
def test_whitespace_is_stripped():
# "a, b" split on comma leaves a leading space on every entry but the
# first; an unstripped key silently creates a second bucket per client.
assert client_key(_Req({"x-forwarded-for": " 203.0.113.7 ,10.0.0.2"})) \
== "203.0.113.7"
-128
View File
@@ -1,128 +0,0 @@
"""Tests for school autosuggest (spec 2026-08-26)."""
from backend import data_loader
class _FakeDocs:
def __init__(self, hits, explode=False):
self._hits = hits
self._explode = explode
self.last_params = None
def search(self, params):
self.last_params = params
if self._explode:
raise RuntimeError("typesense is down")
return {"hits": [{"document": d} for d in self._hits]}
class _FakeClient:
def __init__(self, hits, explode=False):
self.docs = _FakeDocs(hits, explode)
self.collections = {"schools": type("C", (), {"documents": self.docs})()}
_HIT = {
"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school",
}
def _use(monkeypatch, client):
monkeypatch.setattr(data_loader, "_get_typesense_client", lambda: client)
def test_returns_the_fields_a_suggestion_needs(monkeypatch):
# Local authority is not decoration: there are many schools called
# "St Mary's", and a list without it cannot be chosen between.
_use(monkeypatch, _FakeClient([_HIT]))
out = data_loader.suggest_schools_typesense("breck")
assert out == [{
"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school",
}]
def test_a_missing_optional_field_becomes_an_empty_string(monkeypatch):
# phase and school_type are optional in the Typesense schema. A missing
# key must not KeyError in the keystroke path.
_use(monkeypatch, _FakeClient([{"urn": 1, "school_name": "X",
"local_authority": "Y", "postcode": "Z"}]))
out = data_loader.suggest_schools_typesense("x")
assert out[0]["phase"] == "" and out[0]["school_type"] == ""
def test_typesense_unavailable_gives_no_suggestions_rather_than_raising(monkeypatch):
_use(monkeypatch, None)
assert data_loader.suggest_schools_typesense("anything") == []
def test_a_typesense_error_gives_no_suggestions_rather_than_raising(monkeypatch):
_use(monkeypatch, _FakeClient([], explode=True))
assert data_loader.suggest_schools_typesense("anything") == []
def test_the_limit_is_passed_through_and_clamped(monkeypatch):
client = _FakeClient([])
_use(monkeypatch, client)
data_loader.suggest_schools_typesense("x", limit=500)
assert client.docs.last_params["per_page"] == 20
def _client(monkeypatch, rows, *, blow_up_dataframe=False):
from fastapi.testclient import TestClient
from backend import app as app_module
monkeypatch.setattr(app_module, "suggest_schools_typesense",
lambda q, limit=8: rows)
if blow_up_dataframe:
def _boom():
raise AssertionError("the suggest path must not load the DataFrame")
monkeypatch.setattr(app_module, "load_school_data", _boom)
monkeypatch.setattr(app_module, "load_latest_school_data", _boom)
return TestClient(app_module.app, raise_server_exceptions=False)
def test_the_endpoint_returns_suggestions(monkeypatch):
body = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck").json()
assert body["suggestions"][0]["school_name"] == "Brecknock Primary School"
def test_the_endpoint_never_touches_the_dataframe(monkeypatch):
"""The whole reason this is not a mode of /api/schools.
That endpoint filters and sorts 25,000 rows of pandas per query, holding
the GIL. Per keystroke, that is the cost this endpoint exists to avoid.
"""
res = _client(monkeypatch, [_HIT], blow_up_dataframe=True).get("/api/suggest?q=breck")
assert res.status_code == 200
assert res.json()["suggestions"]
def test_a_one_character_query_returns_nothing_and_does_not_error(monkeypatch):
# The keystroke path never errors on ordinary input.
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=b")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_a_blank_query_returns_nothing_and_does_not_error(monkeypatch):
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_typesense_down_is_an_empty_list_not_a_500(monkeypatch):
res = _client(monkeypatch, []).get("/api/suggest?q=breck")
assert res.status_code == 200
assert res.json() == {"suggestions": []}
def test_the_response_is_cacheable(monkeypatch):
# Prefix queries repeat enormously across users, and school names change
# once a year. Without this the endpoint pays full price every keystroke.
res = _client(monkeypatch, [_HIT]).get("/api/suggest?q=breck")
assert "s-maxage" in res.headers.get("cache-control", "")
assert res.headers.get("etag")
+22 -3
View File
@@ -176,11 +176,30 @@ registry is orphaned and nothing reads it.
variable, and set `UNLEASH_URL` to `http://<UNLEASH_IP>:4242/api`.
5. Redeploy the application stacks.
### Adding a flag to Unleash
**Unleash does not create flags by itself.** The SDK reads definitions from the
server and never registers anything, and metrics for a flag the server has
never heard of are discarded. So a flag declared in `backend/flags.py` will be
evaluated on every request, stay `False` forever, and never appear in the UI
until someone creates it there by hand.
For each flag in the registry, create one in Unleash with:
- **Name** — character for character what `backend/flags.py` declares.
snake_case, no hyphens or spaces. A typo produces a flag that looks correct
in the UI and is read by nothing.
- **Type** — Release. No strategies, constraints or variants: these are plain
on/off switches, by design.
### Turning a feature on
Toggle the flag in the environment you want. Flags appear in the Unleash UI
after the backend has evaluated them once, so a newly declared flag shows up
shortly after the deploy that introduced it.
Toggle the flag in the environment matching the stack you mean: **development**
for staging, **production** for prod. The token in each stack is scoped to one
environment, so toggling the other one has no visible effect.
The SDK refreshes every 15 seconds, so the API reflects the change almost at
once; the pages follow on their own schedule, below.
A flip reaches school pages within about five minutes and place pages within
the hour. Next's ISR does the propagating — it revalidates a route at the
File diff suppressed because it is too large. Load diff
@@ -1,253 +0,0 @@
# School Autosuggest — Design
**Date:** 2026-08-26
**Status:** approved for planning
**Depends on:** the feature-flag layer (PR #125, merged)
## Goal
Suggest schools by name as someone types in the site's main search box, so a
parent who knows the school they want reaches it in one step instead of
searching, scanning a result list, and clicking.
Scope is **schools only**. Places and postcodes were considered and excluded —
see *Out of scope*.
## The finding that shapes everything
The site's rate limiter does not do what it looks like it does.
`limiter = Limiter(key_func=get_remote_address)` with `60/minute` reads
`request.client.host`. In staging and production the backend has no published
ports and sits on the internal `backend` network, so its only caller is the
Next proxy — and `request.client.host` is therefore **the Next container**, for
every browser user on the site.
Measured against staging: 70 concurrent requests to `/api/schools` returned
**60 × 200 and 10 × 429**. One machine consumed the whole site's budget for
that minute.
Autosuggest is the worst possible feature to build on that. One person typing
"st marys primary" produces six to eight debounced requests; **eight concurrent
searchers would 429 the site.** The compare modal's search-as-you-type already
shares this bucket, so the exposure exists today — autosuggest makes it
certain.
Fixing the keying is therefore part of this work, not a follow-up.
## 1. Rate-limit keying
Both environments sit behind Cloudflare (`server: cloudflare`, `cf-ray` present
on staging and production). Cloudflare sets `CF-Connecting-IP` on every request
to the origin and **overwrites any client-supplied value**, which makes it
trustworthy in a way a parsed `X-Forwarded-For` chain is not.
```python
def client_key(request: Request) -> str:
"""Rate-limit bucket: the real caller, not the proxy in front of them."""
cf = request.headers.get("cf-connecting-ip")
if cf:
return cf.strip()
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return get_remote_address(request)
```
`nextjs-app/app/api/[...path]/route.ts` already forwards every inbound header
except `host` and `connection`, so `CF-Connecting-IP` reaches the backend with
no proxy change.
Two things make this safe: Cloudflare replaces the header, so a browser cannot
forge it; and the backend is unreachable from outside the Docker network, so
nothing can reach it without passing through the proxy. The `X-Forwarded-For`
fallback *is* forgeable, but only by a caller already inside that network.
### The part that is not free
The shared bucket has been acting as an accidental global throttle on a
single-process uvicorn backend that filters a 25,000-row DataFrame in-process.
Correct per-user keying removes that throttle: the origin becomes reachable at
60/min *per user* rather than 60/min in total.
Per-user fairness and origin protection are different jobs. Conflating them
is what produced the current behaviour, and the fix must not quietly do it
again in the other direction.
**The global ceiling does not go in this app.** An earlier draft of this
section specified one via slowapi's `default_limits`. Reading the library
shows that would not have worked, twice over: `default_limits` and
`application_limits` are both evaluated with the same `key_func`, so they are
per-client across all routes rather than global; and `application_limits` are
only applied `if in_middleware`, while this app installs no `SlowAPIMiddleware`
at all. Expressing a genuine global cap would take a second `Limiter` with a
constant key plus that middleware — two mechanisms to keep correct, for a
protection this layer is the wrong place for.
Cloudflare is already in the request path on both environments and does
edge-level rate limiting properly, before traffic reaches a single-process
origin at all. That is where a global ceiling belongs, and it is a dashboard
change rather than code. Flagged as a follow-up, deliberately not built here.
What ships instead is conservative per-user limits: the existing 60/minute
default is unchanged, and `/api/suggest` gets 120/minute. Both are estimates
rather than measurements, and they are a starting point to revisit once the
keying is correct enough for real per-user traffic to be visible — which it
is not today, because everyone shares one bucket.
## 2. `GET /api/suggest`
A dedicated endpoint, not a mode of `/api/schools`.
The existing search path calls Typesense for URNs and then filters, ranks and
sorts the full in-memory DataFrame — a pandas pass per keystroke, holding the
GIL and blocking other requests in the same worker. Suggestions need none of
it: `urn`, `school_name`, `phase`, `school_type`, `local_authority`,
`postcode` and `ofsted_rating` are all already in the Typesense document
(`pipeline/scripts/sync_typesense.py`).
```
GET /api/suggest?q=<query>&limit=8
→ 200 {"suggestions": [
{"urn": 100010, "school_name": "Brecknock Primary School",
"local_authority": "Camden", "postcode": "NW1 1AA",
"phase": "Primary", "school_type": "Community school"}
]}
```
- **Under two characters** returns `{"suggestions": []}` with 200. The
keystroke path never returns an error for ordinary input.
- **Typesense unavailable** returns `{"suggestions": []}` with 200. There is
deliberately **no DataFrame fallback**: the substring scan `/api/schools`
falls back to is precisely the cost this endpoint exists to avoid, and a
silent 25,000-row scan per keystroke is worse than no suggestions.
- **`limit` is clamped** to 20. It is a public endpoint.
- **Rate limit `120/minute`** per client, not the default 60. A 200 ms
debounce tops out near 5 requests/second while someone is actively typing,
but averages far below that across a real search; 120 leaves headroom for
bursts while still bounding one client.
- **Local authority is part of the payload, not decoration.** There are many
schools called "St Mary's"; a suggestion list without the authority is
unusable for exactly the queries autosuggest is meant to serve.
### Caching
`CACHE_RULES` gains `("/api/suggest", (60, 3600, 86400))`. Prefix queries
repeat enormously across users and school names change once a year.
The client fetch must **not** use `cache: "no-store"`. The compare modal does,
and copying that pattern would throw away both the browser cache and the ETag
304s the existing `CacheAndETagMiddleware` already provides.
Both environments currently report `cf-cache-status: DYNAMIC` — Cloudflare
ignores the `Cache-Control` the API already sends, because it does not cache
dynamic paths by default. **A Cloudflare Cache Rule for `/api/suggest*` would
let the edge absorb most of this traffic and never reach the origin.** That is
a dashboard change, it is optional, and nothing here depends on it.
## 3. The combobox
This is an ARIA combobox, not a text input with a list underneath.
**Files.** `FilterBar.tsx` is already long. The work splits three ways:
`hooks/useSchoolSuggest.ts` owns fetching, debouncing and cancellation;
`components/SuggestList.tsx` owns rendering and ARIA; `FilterBar.tsx` wires
them to the existing input and form.
**Fetching.** 200 ms debounce; minimum two characters; an `AbortController`
cancels the superseded request on every keystroke. Cancellation is not an
optimisation — without it, a slow response for `"st"` can land after the fast
one for `"st marys"` and replace a correct list with a stale one.
**Suppressed during postcode entry.** The box takes a school name *or* a
postcode, and `isValidPostcode` already distinguishes them. Suggestions do not
appear once the value parses as a postcode.
**Keyboard.** `ArrowDown`/`ArrowUp` move the active option, `Escape` closes and
keeps the typed text, `Tab` closes. `Enter` **with an option active** navigates
to that school's page. `Enter` **with none active** submits the free-text
search exactly as it does today — the existing behaviour is preserved, not
replaced.
**ARIA.** `role="combobox"` with `aria-expanded` and `aria-controls` on the
input, `aria-activedescendant` pointing at the active option, `role="listbox"`
on the list and `role="option"` on each row.
**Both instances get it.** `HomeView` renders `FilterBar` twice — hero and
sticky — from one component, so there is one implementation.
## 4. Behind a flag
Flag `school_autosuggest`, declared in `backend/flags.py`, default off.
This is the most-used control on the site and the first change to it in a
while. `app/page.tsx` is an async server component, so it reads the flag and
threads it to `FilterBar` through `HomeView` — two prop hops, explicit, no
client-side flag read.
Off means the input behaves exactly as it does today: no listener, no fetch, no
markup. Not a rendered-then-hidden dropdown.
The rate-limit keying is **not** flagged. It is a correctness fix that should
apply whether or not autosuggest is on, and flagging it would mean shipping a
known-wrong limiter into production deliberately.
## 5. Analytics
`search_submitted` already carries `via: 'input'`. Selecting a suggestion fires
it with `via: 'suggestion'` plus the chosen `urn`, so the obvious question —
does this actually help, or do people ignore it — has an answer in the data
rather than an opinion.
## 6. Testing
**Backend.** `client_key` prefers `CF-Connecting-IP`, falls back through
`X-Forwarded-For` to the remote address, and two different values get two
different buckets. `/api/suggest` returns matches, returns empty below two
characters, returns empty and 200 when Typesense is unavailable, and clamps
`limit`. That it never touches the DataFrame is asserted by making
`load_school_data` raise and requiring the endpoint to answer anyway.
**Frontend.** The hook debounces, aborts superseded requests, and drops a
late-arriving response for a stale query. The list renders the ARIA
attributes. Keyboard navigation moves the active option; `Enter` on an option
navigates; `Enter` on none submits the search.
**E2E.** With the flag on, typing a known school name shows it and selecting it
lands on that school's page. With the flag off, no combobox markup exists.
Gated on the flag the same way the distance journeys are — read the observable
effect, since `/api/flags` is denied to the public.
## 7. Risks
**Removing the accidental throttle.** Covered in §1. Correct per-user keying
means the origin is reachable at 60/minute *per user* where it was 60/minute
in total, and no in-app global cap replaces it — that job goes to Cloudflare,
which is not done as part of this change. Until it is, a determined caller
with many source addresses can put more load on a single-process origin than
they can today. Against this site's traffic that is a theoretical risk rather
than a live one, but it is a real one and it is the price of the fix.
**Cloudflare bypass.** If the origin is reachable without passing through
Cloudflare, `CF-Connecting-IP` is absent and the `X-Forwarded-For` fallback is
forgeable, so limits could be evaded per-request. Closing that properly means
Authenticated Origin Pulls or an origin firewall, which is infrastructure work
outside this change. Worth doing separately.
**Typesense becomes user-visible.** Today a Typesense outage degrades search to
a slow substring match. With autosuggest it also means the dropdown silently
stops appearing. That is the correct failure — quiet, not broken — but it makes
Typesense health worth monitoring in a way it was not before.
## Out of scope
- **Place suggestions.** The 2,646 town, authority and outcode pages are a
strong candidate and would route people onto the pages W2 built, but they
live in the place registry rather than Typesense, so it is a second index and
a ranking rule for comparing two kinds of result. Worth its own change.
- **Postcode completion.** Would put postcodes.io in the keystroke path, with
its own latency and rate limits.
- **The compare modal.** It already has search-as-you-type. Converting it to
this component is a reasonable follow-up, not part of this.
- **Recent or popular searches.** No storage for either, and no evidence yet
that they are wanted.
-63
View File
@@ -2111,66 +2111,3 @@ test('the rankings page still orders by score, not name', async ({ page }) => {
.filter((v: number | null) => v != null);
expect(scores).toEqual([...scores].sort((a: number, b: number) => b - a));
});
/*
* School autosuggest (spec 2026-08-26).
*/
async function autosuggestIsOn(page: Page): Promise<boolean> {
await page.goto('/');
return (await page.getByRole('combobox').count()) > 0;
}
test('the suggest endpoint answers from Typesense', async ({ page }) => {
// Not flagged — the endpoint is live even while the UI is dark, so it can
// be smoke-tested before the feature is switched on.
const res = await page.request.get('/api/suggest?q=brecknock');
expect(res.ok()).toBeTruthy();
const { suggestions } = await res.json();
expect(Array.isArray(suggestions)).toBeTruthy();
if (suggestions.length) {
// Local authority is what tells two "St Mary's" apart.
expect(suggestions[0]).toHaveProperty('school_name');
expect(suggestions[0]).toHaveProperty('local_authority');
}
});
test('a one-character query is answered, not rejected', async ({ page }) => {
// The keystroke path never errors on ordinary input.
const res = await page.request.get('/api/suggest?q=b');
expect(res.status()).toBe(200);
expect((await res.json()).suggestions).toEqual([]);
});
test('the suggest response is cacheable', async ({ page }) => {
const res = await page.request.get('/api/suggest?q=brecknock');
expect(res.headers()['cache-control'] ?? '').toContain('s-maxage');
});
test('typing a school name suggests it, and choosing it opens that school', async ({ page }) => {
test.skip(!(await autosuggestIsOn(page)),
'the school_autosuggest flag is off in this environment');
// A school certain to exist in any environment with data.
const { schools } = await (await page.request.get('/api/schools?page_size=1')).json();
test.skip(!schools?.length, 'no schools in this environment');
const name = schools[0].school_name as string;
await page.goto('/');
await page.getByRole('combobox').first().fill(name.slice(0, 12));
const option = page.getByRole('option').first();
await expect(option).toBeVisible();
await option.click();
await expect(page).toHaveURL(/\/school\/\d+/);
});
test('with autosuggest off, the search box is a plain input', async ({ page }) => {
test.skip(await autosuggestIsOn(page),
'the school_autosuggest flag is on in this environment');
await page.goto('/');
await expect(page.getByRole('combobox')).toHaveCount(0);
// And the box still works: the existing search must be untouched.
await page.getByPlaceholder(/School name or postcode/i).first().fill('abbey');
await page.getByRole('button', { name: /Search/i }).first().click();
await expect(page).toHaveURL(/search=abbey/);
});
@@ -1,76 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FilterBar } from '@/components/FilterBar';
const push = jest.fn();
jest.mock('next/navigation', () => ({
useRouter: () => ({ push, replace: jest.fn(), prefetch: jest.fn() }),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}));
const FILTERS = {
local_authorities: [], school_types: [], years: [], phases: [],
genders: [], admissions_policies: [],
};
const realFetch = global.fetch;
beforeEach(() => {
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ suggestions: [{
urn: 100010, school_name: 'Brecknock Primary School',
local_authority: 'Camden', postcode: 'NW1 1AA',
phase: 'Primary', school_type: 'Community school' }] }),
})) as unknown as typeof fetch;
push.mockClear();
});
afterEach(() => { global.fetch = realFetch; });
describe('FilterBar autosuggest', () => {
it('is a combobox only when the flag is on', () => {
const { rerender } = render(<FilterBar filters={FILTERS} autosuggest={false} />);
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
rerender(<FilterBar filters={FILTERS} autosuggest />);
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('makes no request while the flag is off', async () => {
// Off means off: no listener, no fetch, no markup.
render(<FilterBar filters={FILTERS} autosuggest={false} />);
await userEvent.type(screen.getByPlaceholderText(/School name or postcode/i),
'brecknock');
expect(global.fetch).not.toHaveBeenCalled();
});
it('shows suggestions and navigates when one is chosen', async () => {
render(<FilterBar filters={FILTERS} autosuggest />);
await userEvent.type(screen.getByRole('combobox'), 'brecknock');
const option = await screen.findByRole('option', { name: /Brecknock/ });
await userEvent.click(option);
expect(push).toHaveBeenCalledWith(
expect.stringContaining('/school/100010'));
});
it('suppresses suggestions once the value is a postcode', async () => {
// The box takes a name OR a postcode; suggestions must get out of the way.
//
// fireEvent.change, not userEvent.type: typing sets "N", "NW", "NW1"... and
// "NW1" is not a postcode, so a request for it is correct behaviour. Only
// the settled value is the assertion, so set it in one go.
render(<FilterBar filters={FILTERS} autosuggest />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'NW1 1AA' } });
await new Promise((r) => setTimeout(r, 300)); // past the 200ms debounce
expect(global.fetch).not.toHaveBeenCalled();
});
it('Enter with no active option still submits the free-text search', async () => {
// The existing behaviour is preserved, not replaced.
render(<FilterBar filters={FILTERS} autosuggest />);
const input = screen.getByRole('combobox');
await userEvent.type(input, 'brecknock{Enter}');
// updateURL pushes inside startTransition, so the call is not synchronous.
await waitFor(() => expect(push).toHaveBeenCalledWith(
expect.stringContaining('search=brecknock')));
});
});
@@ -1,50 +0,0 @@
import { render, screen } from '@testing-library/react';
import { SuggestList, suggestOptionId } from '@/components/SuggestList';
const ROWS = [
{ urn: 1, school_name: "St Mary's Primary", local_authority: 'Camden',
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Voluntary aided school' },
{ urn: 2, school_name: "St Mary's Primary", local_authority: 'Barnet',
postcode: 'EN5 2AA', phase: 'Primary', school_type: 'Community school' },
];
describe('SuggestList', () => {
it('is a listbox of options', () => {
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getByRole('listbox')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(2);
});
it('shows the local authority, which is what tells two schools apart', () => {
// Both rows are "St Mary's Primary". Without the authority the list is
// unusable for exactly the query autosuggest exists to serve.
render(<SuggestList id="s" suggestions={ROWS} activeIndex={-1}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getByText('Camden')).toBeInTheDocument();
expect(screen.getByText('Barnet')).toBeInTheDocument();
});
it('marks only the active option selected', () => {
render(<SuggestList id="s" suggestions={ROWS} activeIndex={1}
onPick={() => {}} onHover={() => {}} />);
const options = screen.getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'false');
expect(options[1]).toHaveAttribute('aria-selected', 'true');
});
it('gives each option the id the input will point at', () => {
// aria-activedescendant on the input has to name a real element id, or
// a screen reader announces nothing as the user arrows through.
render(<SuggestList id="s" suggestions={ROWS} activeIndex={0}
onPick={() => {}} onHover={() => {}} />);
expect(screen.getAllByRole('option')[0]).toHaveAttribute(
'id', suggestOptionId('s', 0));
});
it('renders nothing when there is nothing to suggest', () => {
const { container } = render(<SuggestList id="s" suggestions={[]}
activeIndex={-1} onPick={() => {}} onHover={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,73 +0,0 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useSchoolSuggest } from '@/hooks/useSchoolSuggest';
const realFetch = global.fetch;
function mockFetch(rows: unknown[], delayMs = 0) {
global.fetch = jest.fn(async (_url: unknown, init?: { signal?: AbortSignal }) => {
if (delayMs) {
await new Promise((resolve, reject) => {
const t = setTimeout(resolve, delayMs);
init?.signal?.addEventListener('abort', () => {
clearTimeout(t);
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
});
}
return { ok: true, json: async () => ({ suggestions: rows }) };
}) as unknown as typeof fetch;
}
const ROW = {
urn: 1, school_name: 'Brecknock Primary School', local_authority: 'Camden',
postcode: 'NW1 1AA', phase: 'Primary', school_type: 'Community school',
};
describe('useSchoolSuggest', () => {
beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); global.fetch = realFetch; });
it('does not fetch below the minimum query length', () => {
mockFetch([ROW]);
renderHook(() => useSchoolSuggest('b', true));
act(() => { jest.advanceTimersByTime(500); });
expect(global.fetch).not.toHaveBeenCalled();
});
it('does not fetch at all when disabled', () => {
// The flag being off must mean no request, not a hidden dropdown.
mockFetch([ROW]);
renderHook(() => useSchoolSuggest('brecknock', false));
act(() => { jest.advanceTimersByTime(500); });
expect(global.fetch).not.toHaveBeenCalled();
});
it('debounces rather than firing per keystroke', () => {
mockFetch([ROW]);
const { rerender } = renderHook(
({ q }) => useSchoolSuggest(q, true), { initialProps: { q: 'br' } });
rerender({ q: 'bre' });
rerender({ q: 'brec' });
act(() => { jest.advanceTimersByTime(199); });
expect(global.fetch).not.toHaveBeenCalled();
act(() => { jest.advanceTimersByTime(2); });
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it('opens with results once they arrive', async () => {
mockFetch([ROW]);
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
act(() => { jest.advanceTimersByTime(200); });
await waitFor(() => expect(result.current.suggestions).toHaveLength(1));
expect(result.current.open).toBe(true);
});
it('close() hides the list without clearing the query', async () => {
mockFetch([ROW]);
const { result } = renderHook(() => useSchoolSuggest('brecknock', true));
act(() => { jest.advanceTimersByTime(200); });
await waitFor(() => expect(result.current.open).toBe(true));
act(() => { result.current.close(); });
expect(result.current.open).toBe(false);
});
});
-8
View File
@@ -8,7 +8,6 @@ import type { Metadata } from 'next';
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
import { formatAcademicYear } from '@/lib/utils';
import { HomeView } from '@/components/HomeView';
import { getFlags } from '@/lib/flags';
import { HowItWorksSection } from '@/components/HowItWorksSection';
import { EditorialSection } from '@/components/EditorialSection';
@@ -64,11 +63,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// Await search params (Next.js 15 requirement)
const params = await searchParams;
// Server-read: no flag value reaches the browser bundle. Threaded down to
// both FilterBar instances via HomeView.
const flags = await getFlags();
const autosuggest = flags.school_autosuggest === true;
// Parse search params
const page = parseInt(params.page || '1');
const radius = params.radius ? parseFloat(params.radius) : undefined;
@@ -117,7 +111,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const years = dataInfo?.years_available ?? [];
return (
<HomeView
autosuggest={autosuggest}
initialSchools={schoolsData}
filters={resolvedFilters}
totalSchools={total}
@@ -138,7 +131,6 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
return (
<HomeView
autosuggest={autosuggest}
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
filters={emptyFilters}
totalSchools={null}
@@ -48,8 +48,6 @@
display: flex;
align-items: center;
gap: 0.5rem;
/* The suggestion dropdown is absolutely positioned against this box. */
position: relative;
}
/* The hero pill: hairline, soft corner, everything else sits inside it. */
+1 -66
View File
@@ -3,11 +3,8 @@
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode, schoolUrl } from "@/lib/utils";
import { isValidPostcode } from "@/lib/utils";
import { track } from "@/lib/analytics";
import { useSchoolSuggest } from "@/hooks/useSchoolSuggest";
import { SuggestList, suggestOptionId } from "./SuggestList";
import type { Suggestion } from "@/lib/suggest";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
@@ -20,8 +17,6 @@ interface FilterBarProps {
onNearMe?: () => void;
geoState?: "idle" | "requesting" | "error";
geoError?: string | null;
/** Server-read feature flag. Off means no listener, no fetch, no markup. */
autosuggest?: boolean;
}
/**
@@ -53,7 +48,6 @@ export function FilterBar({
onNearMe,
geoState = "idle",
geoError,
autosuggest = false,
}: FilterBarProps) {
const router = useRouter();
const pathname = usePathname();
@@ -68,45 +62,6 @@ export function FilterBar({
const [omniValue, setOmniValue] = useState(initialOmniValue);
const suggestId = `school-suggest-${isHero ? "hero" : "bar"}`;
// Suppressed once the value parses as a postcode: the box takes a school
// name OR a postcode, and suggesting schools during postcode entry fights
// the user rather than helping them.
const suggestEnabled = autosuggest && !isValidPostcode(omniValue);
const { suggestions, open, activeIndex, setActiveIndex, close } =
useSchoolSuggest(omniValue, suggestEnabled);
const pickSuggestion = (s: Suggestion) => {
close();
track('search_submitted', {
query: s.school_name.toLowerCase(),
via: 'suggestion',
urn: s.urn,
has_postcode: false,
filters_active: '',
filters_count: 0,
});
router.push(schoolUrl(s.urn, s.school_name));
};
const handleOmniKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex(activeIndex + 1 >= suggestions.length ? 0 : activeIndex + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex(activeIndex <= 0 ? suggestions.length - 1 : activeIndex - 1);
} else if (e.key === "Escape") {
close();
} else if (e.key === "Enter" && activeIndex >= 0) {
// Only when an option is active. With none, the event falls through to
// the form's submit handler and searches the typed text, as it does now.
e.preventDefault();
pickSuggestion(suggestions[activeIndex]);
}
};
const currentLA = searchParams.get("local_authority") || "";
const currentType = searchParams.get("school_type") || "";
const currentPhase = searchParams.get("phase") || "";
@@ -272,19 +227,8 @@ export function FilterBar({
type="search"
value={omniValue}
onChange={(e) => setOmniValue(e.target.value)}
onKeyDown={handleOmniKeyDown}
onBlur={close}
placeholder="School name or postcode"
className={styles.omniInput}
{...(autosuggest ? {
role: "combobox",
"aria-expanded": open,
"aria-controls": suggestId,
"aria-autocomplete": "list" as const,
"aria-activedescendant":
activeIndex >= 0 ? suggestOptionId(suggestId, activeIndex) : undefined,
autoComplete: "off",
} : {})}
/>
<button
type="submit"
@@ -293,15 +237,6 @@ export function FilterBar({
>
{isPending ? <div className={styles.spinner}></div> : isHero ? "Search schools" : "Search"}
</button>
{autosuggest && open && (
<SuggestList
id={suggestId}
suggestions={suggestions}
activeIndex={activeIndex}
onPick={pickSuggestion}
onHover={setActiveIndex}
/>
)}
</div>
{isHero && (
<>
+1 -5
View File
@@ -29,8 +29,6 @@ interface HomeViewProps {
// show (e.g. an active search).
howItWorks?: React.ReactNode;
editorial?: React.ReactNode;
/** Server-read feature flag, threaded to both FilterBar instances. */
autosuggest?: boolean;
}
function daysUntil(month: number, day: number): number {
@@ -195,7 +193,7 @@ const VALUE_PROPS: ValueProp[] = [
},
];
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial, autosuggest = false }: HomeViewProps) {
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -464,7 +462,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
</div>
@@ -503,7 +500,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
onNearMe={handleNearMe}
geoState={geoState}
geoError={geoError}
autosuggest={autosuggest}
/>
)}
@@ -1,53 +0,0 @@
/*
* Anchored to .omniBoxContainer, which is position: relative for this reason.
*
* Every colour is a token, so the dropdown follows the theme. The dark theme
* redefines --bg-card, --border, --text-muted and --shadow-soft, and this
* inherits all four without a second rule.
*/
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
/* Above the sticky filter bar (10) and the hero layers (0–2), below the
skip-link (10000) and the modal overlay (1000). */
z-index: 40;
margin: 0;
padding: 4px;
list-style: none;
max-height: 320px;
overflow-y: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-soft);
}
.option {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-primary);
}
/* Hover and keyboard share one style: the active option is the active option
however it became active. Two rules would drift. */
.option:hover,
.active {
background: var(--bg-secondary);
}
.name {
font-weight: 500;
}
.meta {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
}
-53
View File
@@ -1,53 +0,0 @@
'use client';
/**
* The autosuggest dropdown. Presentational only — it fetches nothing and owns
* no state, so the fetching rules and the ARIA rules can be read separately.
*/
import type { Suggestion } from '@/lib/suggest';
import styles from './SuggestList.module.css';
/** The id the input's aria-activedescendant points at. */
export function suggestOptionId(id: string, index: number): string {
return `${id}-option-${index}`;
}
interface Props {
/** Shared with the input's aria-controls. */
id: string;
suggestions: Suggestion[];
activeIndex: number;
onPick: (s: Suggestion) => void;
onHover: (index: number) => void;
}
export function SuggestList({ id, suggestions, activeIndex, onPick, onHover }: Props) {
if (suggestions.length === 0) return null;
return (
<ul className={styles.list} id={id} role="listbox">
{suggestions.map((s, i) => (
<li
key={s.urn}
id={suggestOptionId(id, i)}
role="option"
aria-selected={i === activeIndex}
className={`${styles.option} ${i === activeIndex ? styles.active : ''}`}
/*
* onMouseDown, not onClick: the input's blur handler closes the list,
* and blur fires before click — so a click handler never runs. This
* is the classic autosuggest bug where the dropdown is unclickable
* with a mouse while working perfectly with a keyboard.
*/
onMouseDown={(e) => { e.preventDefault(); onPick(s); }}
onMouseEnter={() => onHover(i)}
>
<span className={styles.name}>{s.school_name}</span>
{/* Not decoration: there are many "St Mary's". */}
<span className={styles.meta}>{s.local_authority}</span>
</li>
))}
</ul>
);
}
-58
View File
@@ -1,58 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { fetchSuggestions, SUGGEST_MIN_QUERY, type Suggestion } from '@/lib/suggest';
/*
* Long enough that a fast typist does not fire a request per character, short
* enough that the list feels attached to the keyboard.
*/
const DEBOUNCE_MS = 200;
export function useSchoolSuggest(query: string, enabled: boolean) {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
// Set when the user dismisses the list, so a re-render does not reopen it.
const dismissed = useRef('');
useEffect(() => {
const q = query.trim();
if (!enabled || q.length < SUGGEST_MIN_QUERY || dismissed.current === q) {
setSuggestions([]);
setOpen(false);
return;
}
/*
* Abort the superseded request on every keystroke. This is correctness,
* not economy: without it a slow response for "st" can land after the fast
* one for "st marys" and replace a correct list with a stale one.
*/
const controller = new AbortController();
const timer = setTimeout(async () => {
const rows = await fetchSuggestions(q, controller.signal);
if (controller.signal.aborted) return;
setSuggestions(rows);
setActiveIndex(-1);
setOpen(rows.length > 0);
}, DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, enabled]);
return {
suggestions,
open,
activeIndex,
setActiveIndex,
close: () => {
dismissed.current = query.trim();
setOpen(false);
setActiveIndex(-1);
},
};
}
-39
View File
@@ -1,39 +0,0 @@
/**
* Client for /api/suggest.
*
* No `cache: "no-store"`. The compare modal's search uses it, and copying that
* here would discard both the browser cache and the ETag 304s the backend's
* CacheAndETagMiddleware already provides — on the one endpoint where prefix
* queries repeat most.
*/
export interface Suggestion {
urn: number;
school_name: string;
local_authority: string;
postcode: string;
phase: string;
school_type: string;
}
/** Below this the response is thousands of schools and worth no round trip. */
export const SUGGEST_MIN_QUERY = 2;
const API = process.env.NEXT_PUBLIC_API_URL || '/api';
/** Suggestions for `q`. Never throws: no suggestions is a fine outcome. */
export async function fetchSuggestions(
q: string, signal?: AbortSignal,
): Promise<Suggestion[]> {
if (q.trim().length < SUGGEST_MIN_QUERY) return [];
try {
const res = await fetch(`${API}/suggest?q=${encodeURIComponent(q.trim())}`,
{ signal });
if (!res.ok) return [];
const body = await res.json();
return body.suggestions ?? [];
} catch {
// Includes AbortError, which is the normal path on every keystroke.
return [];
}
}