Skip to content

fix(checkout): failure banner rendered "undefined" on full-page return before i18n loaded#4455

Merged
koala73 merged 1 commit into
mainfrom
fix/checkout-failure-banner-i18n-fallback
Jun 26, 2026
Merged

fix(checkout): failure banner rendered "undefined" on full-page return before i18n loaded#4455
koala73 merged 1 commit into
mainfrom
fix/checkout-failure-banner-i18n-fallback

Conversation

@koala73

@koala73 koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner

The bug (prod, post-#4454)

After the redirect-mode switch (#4449/#4454), a failed payment does a full-page redirect back to the dashboard. panel-layout renders the checkout-failure banner immediately during the cold init — before i18next's full bundle has loaded (i18n loads a shell subset first, then the full bundle async). So t('components.checkoutFailureBanner.message') returned undefined and the user saw a red "undefined" banner (confirmed on prod after a real declined 3DS payment).

The success banner is immune — its strings are hardcoded English (checkout.ts:1221), and it defers render via waitForEntitlement. Redirect mode is what made the immediate cold-load failure-banner path common (the old overlay never reloaded the page).

Fix

Give every t() call in checkout-failure-banner.ts a defaultValue (English, matching en.json): message, retry, retrying, dismiss. The banner now renders correct English regardless of i18n readiness, and still picks up translations once the bundle loads. 5 lines, lint clean, no test asserted the raw key.

Context

This is the only residual from the 3DS redirect work — which is confirmed working end-to-end on prod: 3DS now runs top-level (Stripe 3DS + bank ACS), success shows the proper green banner, and failures return to /dashboard?wm_checkout=return. Part of #4440.

Refs #4449 #4454

…before i18n loaded

After the #4449 redirect-mode switch, a failed payment does a full-page redirect back to
the dashboard, and panel-layout renders the checkout-failure banner during the cold init —
before i18next's full bundle has loaded. `t('components.checkoutFailureBanner.message')`
returned undefined, so the user saw a red "undefined" banner (confirmed on prod after a
real declined 3DS payment). The success banner is unaffected — its strings are hardcoded,
and it also defers render via waitForEntitlement.

Give every t() call in the banner a defaultValue (English, matching en.json) so it renders
correctly regardless of i18n readiness and still picks up translations once the bundle loads.

Refs #4449 #4454
@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 26, 2026 1:08pm

Request Review

@koala73 koala73 added bug Something isn't working UX/UI User interface and experience P1 High priority, fix soon labels Jun 26, 2026
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a production bug where the checkout-failure banner displayed "undefined" text after the redirect-mode 3DS return, caused by t() calls resolving before i18next's full translation bundle had loaded. The fix adds defaultValue options to all four t() calls in checkout-failure-banner.ts, matching the en.json strings exactly.

  • All four translation keys (message, retry, retrying, dismiss) now have English fallbacks, preventing the raw undefined value from reaching the DOM during the cold-init window when only the shell i18n bundle is loaded.
  • The fix is correctly scoped: retry and retrying are called on user interaction (after the bundle will have loaded), so the defaults there are defensive; message and dismiss are emitted into static HTML during initial render where the race is live.

Confidence Score: 4/5

Safe to merge — the change is a minimal, targeted fix for a confirmed production regression with no functional regressions introduced.

All four t() calls now carry defaultValue strings that match en.json exactly, and the i18next defaultValue option is the canonical mechanism for this race. The one thing to be aware of: the message and dismiss strings are baked into static HTML at render time, and the container-scoped wm:i18n:resources-loaded healer does not reach this body-level banner — so non-English users who hit the failure banner during the cold-init window will see permanent English text rather than their locale. This is a pre-existing healer scope limitation and strictly better than the previous undefined output, but it means the PR description's claim that strings still pick up translations once the bundle loads is only true for the button-click paths.

src/components/checkout-failure-banner.ts — straightforward and correct; no changes needed before merging.

Important Files Changed

Filename Overview
src/components/checkout-failure-banner.ts Adds defaultValue to all four t() calls; values match en.json exactly and the i18next defaultValue option is the canonical fix for the missing-key-before-bundle race.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant App as App.ts (cold init)
    participant i18n as i18n service
    participant Banner as checkout-failure-banner.ts

    Browser->>App: "Full-page redirect back (/dashboard?wm_checkout=return&status=failed)"
    App->>i18n: initI18n() — loads en.shell.json (sync)
    Note over i18n: checkoutFailureBanner keys NOT in shell
    App->>i18n: preloadEnglishTranslation() fire-and-forget
    App->>Banner: showCheckoutFailureBanner(rawStatus)
    Banner->>i18n: "t('...message', {defaultValue: "Payment couldn't be completed..."})"
    Note over Banner: BEFORE fix: t() → undefined → "undefined" in DOM<br/>AFTER fix: t() → defaultValue → correct English text
    Banner->>Browser: Render red banner with English fallback text
    i18n-->>App: wm:i18n:resources-loaded event (full bundle ready)
    App->>App: replaceRawI18nKeyPlaceholders(container) — heals container scope only
    Note over Banner: Banner is body-level (outside container) — not re-healed<br/>retry/retrying text resolved correctly on user click
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant App as App.ts (cold init)
    participant i18n as i18n service
    participant Banner as checkout-failure-banner.ts

    Browser->>App: "Full-page redirect back (/dashboard?wm_checkout=return&status=failed)"
    App->>i18n: initI18n() — loads en.shell.json (sync)
    Note over i18n: checkoutFailureBanner keys NOT in shell
    App->>i18n: preloadEnglishTranslation() fire-and-forget
    App->>Banner: showCheckoutFailureBanner(rawStatus)
    Banner->>i18n: "t('...message', {defaultValue: "Payment couldn't be completed..."})"
    Note over Banner: BEFORE fix: t() → undefined → "undefined" in DOM<br/>AFTER fix: t() → defaultValue → correct English text
    Banner->>Browser: Render red banner with English fallback text
    i18n-->>App: wm:i18n:resources-loaded event (full bundle ready)
    App->>App: replaceRawI18nKeyPlaceholders(container) — heals container scope only
    Note over Banner: Banner is body-level (outside container) — not re-healed<br/>retry/retrying text resolved correctly on user click
Loading

Reviews (1): Last reviewed commit: "fix(checkout): failure banner showed "un..." | Re-trigger Greptile

Comment on lines +75 to +77
<span>${t('components.checkoutFailureBanner.message', { defaultValue: "Payment couldn't be completed. No charge was made." })}</span>
${retryButton}
<button id="cf-dismiss-btn" aria-label="${t('components.checkoutFailureBanner.dismiss')}" style="background:transparent;color:#fff;border:none;cursor:pointer;font-size:18px;padding:0 4px;line-height:1;">&times;</button>
<button id="cf-dismiss-btn" aria-label="${t('components.checkoutFailureBanner.dismiss', { defaultValue: 'Dismiss' })}" style="background:transparent;color:#fff;border:none;cursor:pointer;font-size:18px;padding:0 4px;line-height:1;">&times;</button>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Static message/dismiss strings won't be re-localized after bundle loads for non-English users

The message span and dismiss aria-label are baked into the static HTML string at render time. The wm:i18n:resources-loaded healer (handleI18nResourcesLoaded) is scoped to this.state.container, which explicitly excludes body-level overlays — and this banner appends to document.body directly. So a French user who lands on this banner during the cold-init window will see English text permanently; the healer never reaches it. The retry/retrying strings are fine since they're set on click (after the bundle has loaded).

This is a pre-existing architectural limitation of the container-scoped healer, not introduced by this PR, and English is strictly better than "undefined". Worth noting if full localization of this path is ever required — the fix would be to extend the healer's scope or re-invoke showCheckoutFailureBanner on the wm:i18n:resources-loaded event (after removing the idempotency guard).

@koala73

koala73 commented Jun 26, 2026

Copy link
Copy Markdown
Owner Author

Reviewed Greptile's note (verified at head 9512941016). It's valid, and a fair correction to my PR description.

Confirmed: replaceRawI18nKeyPlaceholders is invoked in App.ts scoped to this.state.container; this banner is document.body.appendChild (line 80), outside that scope. And the healer swaps raw key placeholders → my defaultValue renders English text (not a raw key), so it wouldn't be re-translated even if the healer reached it.

Net: for the statically-rendered message/dismiss, a non-English user who hits a payment failure during the cold-init window sees correct English, not their locale. The click-path retry/retrying resolve to locale (rendered after the bundle loads). So my description's "still picks up translations once loaded" is only true for the button paths — correcting that here.

Decision: ship as-is. English-on-a-narrow-cold-init-failure is strictly better than undefined, the intersection (non-English × failure × pre-bundle window) is tiny, and Greptile concurs (4/5, no changes needed).

If we want perfect i18n there later, the clean follow-up is a one-time I18N_RESOURCES_LOADED_EVENT listener on the mounted banner that re-runs t() on its text — happy to file it, but it's not worth blocking this prod-bug fix.

@koala73
koala73 merged commit 89e78c6 into main Jun 26, 2026
24 checks passed
@koala73
koala73 deleted the fix/checkout-failure-banner-i18n-fallback branch June 26, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working P1 High priority, fix soon UX/UI User interface and experience

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant