Skip to content

fix: prevent black screen when switching languages mid-load#1

Open
cat5inthecradle wants to merge 3 commits into
mainfrom
fix/language-switch-black-screen
Open

fix: prevent black screen when switching languages mid-load#1
cat5inthecradle wants to merge 3 commits into
mainfrom
fix/language-switch-black-screen

Conversation

@cat5inthecradle

@cat5inthecradle cat5inthecradle commented Jun 15, 2026

Copy link
Copy Markdown

Problem

QA on the iframe embed reported (and reproduced) that switching languages before the previous language finishes rendering its message-box text crashes the widget to a black screen, with audio continuing to play. Reported on Chrome 149 / Windows 11; reproduced locally.

Root cause (from reading the oceans-lab bundle)

OceansLab keeps all locale-dependent state in module-level singletons, not React state:

  • let rc — a cached React root for the UI overlay (text bubble, speaker, guide). Created once (rc || (rc = createRoot(...))), never reset.
  • a global mutable lab state object (getState/setState/resetState)
  • the audio/TTS engine (Sounds.getSingleton(), speechSynthesis)
  • pending timers (e.g. the guide typing timer)

The lab's mount effect does re-run initAll when strings/textToSpeechLocale change, but its cleanup only calls cancelAnimationFrame — it never stops speechSynthesis/sounds, clears timers, or resets the overlay root. So any React-level locale swap leaves the overlay root pointing at a torn-down node (→ missing text bubble/speaker) and audio still playing (→ lingering speech). On code.org the lab never hits this: a locale change there is a full page reload, so the globals are always rebuilt from scratch.

Fix

Make a language change a full iframe reload with the new ?lang= param, matching code.org's model:

  • reloadWithLang() cancels speechSynthesis, sets ?lang=, and reloads.
  • Navigation tears down every module singleton; the lab re-inits cleanly for the new locale via the known-good fresh-boot path.
  • Progress persists in sessionStorage, so the user stays on the same mode; only the current mode's animation restarts (same as code.org).
  • Keeps the ErrorBoundary (defense-in-depth); reverts the earlier key-remount / deferred-apply approach, which didn't fix the crash and added the audio/overlay symptoms.

Testing

  • pnpm build (tsc + vite) — passes
  • pnpm exec playwright test e2e/locale.spec.ts — 8/8, including a regression test asserting a language switch reloads to the new ?lang and the lab renders in the new locale
  • Correctness rationale: a reload to ?lang=X is identical to a fresh page load in X, which is the already-verified working path

Notes

🤖 Generated with Claude Code

Rapidly switching languages before the lab's message-box text finished
rendering could crash the widget to a black screen (reported on the iframe
embed; Chrome 149 / Win11). Three compounding causes:

- The OceansLab instance was hot-swapped with new strings/textToSpeechLocale
  props mid-animation. The lab doesn't support live locale swaps — on
  code.org a locale change is a full page reload. Add key={locale} so the
  lab cleanly remounts instead of mutating in place.
- The loadStrings effect had no cancellation: two in-flight locale chunks
  could resolve out of order and land stale strings mid-render. Guard with a
  stale flag and only apply a locale once its strings chunk resolves.
- An uncaught error unmounted the whole React tree, leaving an empty #root
  over the dark page background (the "black screen"). Add an ErrorBoundary
  with a reload fallback so a crash degrades to a recoverable message.

Adds an e2e regression test for rapid back-to-back language switching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cat5inthecradle

Copy link
Copy Markdown
Author

Filed the upstream defense-in-depth issue: code-dot-org/code-dot-org#73241 (lab should tolerate live strings/textToSpeechLocale changes or document them as unsupported). The key remount in this PR means the widget no longer triggers it regardless.

Comment thread src/ErrorBoundary.tsx
Comment on lines +23 to +25
componentDidCatch(error: unknown, info: React.ErrorInfo) {
console.error('AI for Oceans crashed:', error, info.componentStack);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might not be the best UI experience, but it's better than a black screen, and there's no reason I can think of not to expose the error details to aid troubleshooting.

@cat5inthecradle cat5inthecradle marked this pull request as draft June 15, 2026 17:45
@cat5inthecradle

Copy link
Copy Markdown
Author

Converting back to a draft because there appear to be some issues after changing languages.

The previous key-remount approach didn't actually fix the black screen and
introduced new symptoms (lingering audio, missing text bubble/speaker). Root
cause, confirmed by reading the oceans-lab bundle:

- The lab keeps locale-dependent state in module-level singletons: a cached
  React root for the UI overlay (`let rc`, created once, never reset), a global
  mutable state object, the audio/TTS engine, and pending timers.
- Its mount effect re-runs initAll when strings/textToSpeechLocale change, but
  the cleanup only cancels the animation frame (`cancelAnimationFrame`) — it
  never stops speechSynthesis/sounds, clears timers, or resets the overlay root.
- So a React-level locale swap (key remount or in-place prop change) leaves the
  overlay root pointing at a torn-down node (no text bubble/speaker) and audio
  playing. The lab was built for code.org, where a locale change is a full page
  reload and these globals never survive a switch.

Fix: make a language change a full iframe reload with the new ?lang= param,
matching code.org. speechSynthesis is cancelled before reload; everything else
is torn down by navigation and re-inits cleanly. Progress persists in
sessionStorage, so the user stays on the same mode.

Reverts the key/deferred-apply prop logic; keeps the ErrorBoundary. Updates the
locale e2e tests for reload semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cat5inthecradle

Copy link
Copy Markdown
Author

Updated the approach after manual testing showed the key-remount didn't actually fix it (audio kept playing, text bubble/speaker disappeared). Read the oceans-lab bundle: the lab holds locale state in module-level singletons (cached overlay React root rc, global state, audio/TTS, timers) and its effect cleanup only cancels the rAF — so no React-level swap can work. Switched to a full iframe reload on language change, matching code.org. See updated description + commit c5f8cf8.

The lab overloads textToSpeechLocale: setting it (a) selects a spoken-TTS
voice, (b) silences the guide "typing" sound effect — which only plays when
the prop is unset — and (c) chooses the ICU plural-rule locale. The wrapper
passed the active locale for every non-English language, which muted the
typing sound for all of them and produced spoken audio only for Italian (the
one locale with voice data); the other 21 EU locales got no guide audio at all.

Stop passing textToSpeechLocale entirely so the typing sound plays in every
locale and no spoken TTS runs. Side effect: the lab then compiles catalogs with
English plural rules and its ICU compiler throws on `few`/`many` branches (e.g.
?lang=pl: "Invalid key `few`"). Strip plural categories English doesn't define
(zero/two/few/many) from catalog strings on load, keeping one/other. Only
fishshort/fishlong-pond-init1 use extra categories today; their plurals now
follow English rules.

Adds a ?lang=pl regression test (loads without the error screen).

Upstream tracking of the prop overloading: code-dot-org/code-dot-org#73241

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cat5inthecradle

Copy link
Copy Markdown
Author

Follow-up in this PR: guide audio across locales (commit c1facaf)

Manual QA of the reload fix surfaced a second, pre-existing issue. The lab's textToSpeechLocale prop is overloaded — it selects the spoken-TTS voice, gates the guide "typing" sound effect (which only plays when the prop is unset), and sets the ICU plural-rule locale. The wrapper passed the active locale for every non-English language, so the typing sound was silenced everywhere except English, and only Italian (the lone locale with voice data) produced any guide audio at all.

Fix: stop passing textToSpeechLocale → bubbly typing sound plays in every locale, no spoken TTS. Side effect: the lab then compiles catalogs with English plural rules and its ICU compiler throws on few/many branches (?lang=plInvalid key 'few'). So loadStrings now strips plural categories English doesn't define (zero/two/few/many), keeping one/other. Only fishshort/fishlong-pond-init1 use extra categories; their plurals now follow English rules — documented trade-off.

Added a ?lang=pl regression test (loads without the error screen). Full locale suite: 9/9.

Verified manually in the iframe embed: typing sounds play in all languages and stop correctly on switch, no spoken Italian, mid-text language change reloads cleanly with no black screen.

Upstream tracking of the prop overloading: code-dot-org/code-dot-org#73241 (comment).

@cat5inthecradle cat5inthecradle marked this pull request as ready for review June 22, 2026 17:47
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.

1 participant