fix(templates): stop the typed headline crashing under browser translation - #172
Open
sudoviber wants to merge 1 commit into
Open
fix(templates): stop the typed headline crashing under browser translation#172sudoviber wants to merge 1 commit into
sudoviber wants to merge 1 commit into
Conversation
…ation
A browser translator (Chrome/Edge/Safari) rewrites text by MOVING each text
node into an injected `<font>` wrapper, which silently invalidates React's
record of that node's parent. `TypedLine` re-renders its text every 26-55ms
and renders `head` as a BARE text node beside a `<span>` sibling; when the
caret reaches a word start `head` becomes `''`, so React REMOVES that text
node rather than just updating its value - and the translator has already
re-parented it into `<font>`. React throws `NotFoundError: Failed to execute
'removeChild' on 'Node'` and the whole route drops into its error boundary.
Opt the headline out with `translate="no"`. One fix covers three surfaces:
`AgentLoopsHeadline` renders on /templates, the dashboard catalog band and
the pre-login landing.
Verified in a real browser against a translator simulation that moves text
nodes into `<font>` and honours `translate="no"` the way Chrome/Edge/Safari
do:
- before: the exact reported error, route replaced by "Something went wrong!".
Instrumenting `removeChild` pinned 1 failure out of 339 wrapped text nodes,
and it was TypedLine's `{tail}` (expected parent `span.whitespace-nowrap`,
actual parent `<font>`).
- after: 0 removeChild failures, no React errors, boundary untouched, the
typing animation still running, all 23 cards intact across 6 filter cycles.
- surgical: 0 `<font>` wrappers inside the headline, 336 outside - the rest of
the page still translates normally.
Element-level updates are unaffected (the translator only re-parents text
nodes), so the search filter was never a second failure surface - measured
with the headline excluded, not assumed.
The new test asserts the attribute sits on an ANCESTOR of the animated
subtree, so moving it to a sibling fails the guard instead of silently
un-fixing the crash.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
With the browser's built-in translation turned on,
/templatesthrows and the wholeroute is replaced by the router's error boundary:
Reported on a self-hosted instance (2026-07-31), reproducible on any Chromium/WebKit
browser with translation enabled.
Root cause
Three things compound:
injected
<font style="vertical-align: inherit;">wrapper. That silently invalidatesReact's record of the node's parent.
TypedLinere-renders its text every 26-55ms, and rendersheadas a bare textnode beside a
<span>sibling.cut === -1soheadbecomes''— React thenremoves that text node instead of just updating its value. The node has already
been re-parented into
<font>, soremoveChildthrows.Instrumenting
Node.prototype.removeChildin a real browser pinned it exactly:1 failure out of 339 wrapped text nodes — the animated one. Everything else on the
page is static, so React never removes those nodes.
The fix
translate="no"on the element that wraps the animated subtree. One fix covers threesurfaces, since
AgentLoopsHeadlinerenders on/templates, the dashboard catalog bandand the pre-login landing.
The attribute is load-bearing rather than cosmetic, so the reasoning is inlined as a
comment next to it.
Verification
Tested in a real browser against a translator simulation that moves text nodes into
<font>and honourstranslate="no"the way Chrome/Edge/Safari do:removeChildfailuresNotFoundErrorSomething went wrong!<font>inside headline<font>elsewhere on pageThe fix is deliberately surgical: only the subtree that mutates text at animation rate
opts out; all the surrounding prose still translates.
Scope was measured, not assumed. With the headline excluded from translation, 6 rounds
of search filtering (including filtering every card away and back) produced 0 failures
— the translator only re-parents text nodes, so element-level removal (cards) is safe.
That ruled out a second failure surface.
I also checked
HousekeeperCinematic, which updates numbers every frame:hk-scoreis its<span>'s only child andhk-dayrenders["Day ", n]withn >= 1, so neither canbecome an empty string and React only ever updates
nodeValue. Structurally safe, nochange needed.
Tests
AgentLoopsHeadline.test.ts— 4 cases covering both theh1and compacth2variants,that rendering is unchanged, and critically that the attribute sits on an ancestor of the
animated subtree, so relocating it to a sibling fails the guard instead of silently
un-fixing the crash.
Confirmed the guard actually bites by removing the attribute: 3 of 4 assertions fail;
restored, all pass.
pnpm -r typecheck— both packages cleanpnpm --filter @loopany/server test— 861 passedOne unrelated pre-existing failure remains:
loopDetailCrossTeam.test.tsfails withEACCES: permission denied, mkdirbecause it writes evidence into a hardcoded absolutetemp path. Verified it fails identically on unmodified
main(git stash), so it is notfrom this change. Happy to fix it separately (
os.tmpdir()+ a unique subdir) if wanted.🤖 Generated with Claude Code