A value that reaches a page lands somewhere specific, and where it lands decides what would make it dangerous. In element text, the character that matters is the angle bracket. Inside a quoted attribute, it is the quote — the brackets are inert there. Inside a script block, neither of them matters and the quote that matters is a different one. In an href, the whole question is the scheme.
One escaping function cannot serve all four, and the one everybody writes serves the first. That is not carelessness; it is what escapeHtml is for. The mistake is calling it everywhere.
GET /orders/search?q=%3Cb%3Ehi%3C%2Fb%3EThe term is rendered twice — once inside an attribute, once as text. Look at how each one was encoded.
This build's template engine picks the encoder by tokenizing the template and asking which context each slot sits in. A slot in element text gets HTML escaping; one inside an attribute gets its quotes as well; one inside a script block gets JSON encoding, because HTML escaping there does nothing at all.
Turn that off — replace it with one HTML escaper applied everywhere — and the element-text slots stay safe while the attribute ones stop being. The quote survives, it ends the attribute, and what follows is parsed as markup.
GET /orders/search?q=%22%20onmouseover%3D%22alert(1)The same request against both builds. One renders a value; the other renders an attribute.
This is also why 'sanitise on input' is an answer to a different question. At the moment a value arrives you do not know where it will end up — the same comment might be rendered as text today and into an attribute next release — and a value cleaned for one context is not clean for another.
Encode on output, for the context you are writing into. That is knowable at exactly the moment you need it, and nowhere else.