Skip to content

fix(templates): stop the typed headline crashing under browser translation - #172

Open
sudoviber wants to merge 1 commit into
superdesigndev:mainfrom
sudoviber:fix/translate-crash-typed-headline
Open

fix(templates): stop the typed headline crashing under browser translation#172
sudoviber wants to merge 1 commit into
superdesigndev:mainfrom
sudoviber:fix/translate-crash-typed-headline

Conversation

@sudoviber

Copy link
Copy Markdown

The bug

With the browser's built-in translation turned on, /templates throws and the whole
route is replaced by the router's error boundary:

Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

Reported on a self-hosted instance (2026-07-31), reproducible on any Chromium/WebKit
browser with translation enabled.

Root cause

Three things compound:

  1. A browser translator does not edit text in place — it moves each text node into an
    injected <font style="vertical-align: inherit;"> wrapper
    . That silently invalidates
    React's record of the node's parent.
  2. TypedLine re-renders its text every 26-55ms, and renders head as a bare text
    node beside a <span> sibling
    .
  3. When the caret reaches a word start, cut === -1 so head becomes '' — React then
    removes that text node instead of just updating its value. The node has already
    been re-parented into <font>, so removeChild throws.

Instrumenting Node.prototype.removeChild in a real browser pinned it exactly:

child:           #text("you")                      ← TypedLine's {tail}
expectedParent:  <span class="whitespace-nowrap">  ← TemplatesPage.tsx:215
actualParent:    <font>                            ← translator's wrapper
ancestorChain:   font > span.whitespace-nowrap > span.block > h1.mx-auto > section.market-hero

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 three
surfaces
, since AgentLoopsHeadline renders on /templates, the dashboard catalog band
and 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 honours translate="no" the way Chrome/Edge/Safari do:

before after
removeChild failures 1 0
React errors NotFoundError none
Error boundary Something went wrong! untouched
Typing animation dead (route crashed) running
Cards across 6 filter cycles n/a 23 intact
<font> inside headline 3 0
<font> elsewhere on page 336 336 (still translated)

The 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-score is its
<span>'s only child and hk-day renders ["Day ", n] with n >= 1, so neither can
become an empty string and React only ever updates nodeValue. Structurally safe, no
change needed.

Tests

AgentLoopsHeadline.test.ts — 4 cases covering both the h1 and compact h2 variants,
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 clean
  • pnpm --filter @loopany/server test861 passed

One unrelated pre-existing failure remains: loopDetailCrossTeam.test.ts fails with
EACCES: permission denied, mkdir because it writes evidence into a hardcoded absolute
temp path. Verified it fails identically on unmodified main (git stash), so it is not
from this change. Happy to fix it separately (os.tmpdir() + a unique subdir) if wanted.

🤖 Generated with Claude Code

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants