Checkout loader lifecycle
Implementation scope: the route-local Paddle payment-link page and the shared plan button's pending treatment. No provider switch, server changes, deployment, production payment, subscription mutation, credentials or CSP widening.
Authoritative Paddle references
Verified against the Paddle developer documentation during implementation:
- https://developer.paddle.com/paddle-js/events/checkout-loaded
checkout.loadedis emitted when a checkout is created and loaded on the page, once per checkout. This is the overlay-open/loading settlement signal. There is nocheckout.openedlistener in this implementation; the browser regression deliberately fires that invented name as a negative control. Areadyvalue indata.statusdescribes payment-field completeness, not initial UI readiness. - https://developer.paddle.com/paddle-js/events/checkout-error
checkout.erroris a fatal checkout startup error. A warning or failed payment attempt is not equivalent: neither should tear down a customer's usable checkout. - https://developer.paddle.com/paddle-js/events/checkout-completed Completion may mean instant capture or authorization pending capture. The page says "Checkout completed", not "Payment received". Backend webhooks, not this browser event, remain authoritative for account/credit state.
- https://developer.paddle.com/paddle-js/events/checkout-closed Both user dismissal and
Paddle.Checkout.close()may emit this event. Mark a startup failure terminal BEFORE closing so it cannot become a canceled redirect. - https://developer.paddle.com/paddle-js/methods/paddle-initialize Initialize is allowed only once per page. A page ref and module-level guard protect against repeated effects, refetches, concurrent calls and SPA revisits.
- https://developer.paddle.com/build/transactions/default-payment-link Explicit
Checkout.open({transactionId})takes priority over automatic_ptxnhandling. Transaction validation and trusted same-origin URL checks are unchanged.
State and recovery decisions
- Loading spans summary, CDN script and the interval between
Checkout.openreturning andcheckout.loaded. ResolvingopenPaddleCheckoutis NOT readiness. - The checkout summary uses its own
["billing", "checkout", transaction]snapshot rather than sharing the sidebar credits query's in-flight promise. Otherwise the sidebar's request can bypass the checkout timeout and network-error handling. The sidebar still makes its ordinary summary request even for an invalid link. - Summary fetch, script load and overlay startup each have a 20-second bound. These are local UX limits, not Paddle's SLA or a payment deadline. There is no deadline on a loaded overlay while the customer is entering payment details.
- HTTP, network, SDK-global/script, invalid transaction, disabled/wrong provider, missing/invalid configuration, thrown startup and
checkout.errorfailures render an alert instead of an animation. No arbitrary provider event detail is rendered. The SDK is never requested with an invalid transaction/config/provider. - Loaded events are idempotent; completed/closed/failure are terminal. Duplicates and late events cannot restart the animation, cancel a completed checkout, or schedule repeated redirects. Completion retains the existing 1.5-second delay; cancellation retains the full navigation to
/billing?checkout=canceled. - Retry is an ordinary same-origin link to the validated payment-link URL. It reloads the document instead of calling Initialize a second time. Back to billing is also a full navigation and is available during loading and errors. Leaving via the SPA navigation during SDK load suppresses late initialization; revisiting without a reload offers the same safe recovery rather than re-init.
- Existing sandbox selection, trusted URL/transaction helpers, missing-email remediation, route-local SDK loading and nginx CSP are preserved.
Accessibility and presentation
A lime-accent ring and lock mark match the existing dark dashboard; animation is purely decorative (aria-hidden). A stable polite role=status, atomic text and screen-reader-only neutral label announce "Loading checkout…" once, not again for each asynchronous stage. Loading errors use an alert and clear that status. The spinner is removed at checkout.loaded/terminal/failure, not simply hidden behind the provider. The card keeps the same bounding box between loading and ready. motion-safe:animate-spin with motion-reduce:animate-none leaves a static mark under reduced motion. Selected plan buttons show a small decorative spinner plus "Processing…", retain their plan-specific accessible name, expose aria-busy and disable all plan actions while pending. No full-page loader is used in buttons. Those buttons render inside the shared Billing Upgrade picker, so the two /billing plan-control regressions open that picker first; the pending treatment itself is unchanged by that composition.
Scoped verification
Run inside the isolated worktree with Node 24 (system Node 12 is insufficient):
export PATH=/home/nyanpasu/.local/share/fnm/node-versions/v24.22.1/installation/bin:$PATH
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @scrapingcow/ui typecheck
corepack pnpm --filter @scrapingcow/ui build
BROWSER_SWEEP_REQUIRED=1 node --test apps/ui/tests/billing.test.mjs apps/ui/tests/paths.test.mjs apps/ui/tests/billing-routes.test.mjs apps/ui/tests/checkout-loader.test.mjs apps/ui/tests/upgrade-sheet.test.mjs
checkout-loader.test.mjs drives the actual built route and SDK adapter in Chrome. Only API and CDN boundaries are mocked; the SDK mock is intentionally inert until tests fire documented events. Unexpected external requests are aborted, service workers are blocked, and no provider is contacted. Set CHECKOUT_EVIDENCE_DIR to save desktop, mobile, reduced-motion, ready, completed and failure screenshots. The "ready" screenshot shows our background state, NOT a real Paddle overlay. Tests also measure bounding boxes, live-region text mutations, reduced-motion computed styles, safe reload recovery, one-time initialization and terminal events.
Full UI suites and independent integrated-commit desktop/mobile/accessibility and local live-stack verification belong to reviewer t_93d09268. A local mocked pass does not prove provider availability, real overlay rendering, production behavior, webhook delivery or deployment. Manual assistive-technology listening and real sandbox provider compatibility remain distinct from DOM/announcement evidence.