Skip to content

perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430

Open
rusikv wants to merge 13 commits into
thingsboard:mainfrom
rusikv:pricing-page-optimization
Open

perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430
rusikv wants to merge 13 commits into
thingsboard:mainfrom
rusikv:pricing-page-optimization

Conversation

@rusikv
Copy link
Copy Markdown
Contributor

@rusikv rusikv commented May 26, 2026

Summary

Performance + UX optimizations targeting /pricing/. Mobile PageSpeed Insights flagged INP at 220 ms (failing the >200 ms threshold) and YourGPT's Inter font on the LCP critical chain. This branch addresses those plus several follow-ups that surfaced during the audit.

Performance

  • INP: rAF-batch slider input handlers, cache CSS variable reads via window._sliderColors, delegate per-element click listeners.
  • Script Evaluation: lazy-load the six pricing calculators (~104 KiB) via dynamic import() — fetched only on first open / on pricing:product-activated for inline TBMQ.
  • LCP critical chain: defer YourGPT widget (and its Inter font from Google Fonts) until first user interaction on the page; eager-load only for users who previously opened the chat (sessionStorage tb:chatbot-opened).
  • Forced reflow: replace JS-positioned <div class="segment-highlight"> with per-tab CSS background, eliminating getBoundingClientRect() reads after class writes.
  • Tooltip / FAQ binds: deferred to requestIdleCallback so they don't compete with the LCP paint.

UX

  • Pre-paint inline <script> syncs the URL state (?section=, ?product=, ?solution=) to the DOM before first paint — deep-link visitors see the correct tabs + correct billing toggle immediately, with no flash of SSR defaults.
  • Fix perpetual calculator CTA scrolling page to top (async openTbPerpCalc raced the href="#" follow).
  • Remove the URL-driven ?calculator=… calc-open feature: the contract diverged from the legacy site, the default-fallback mapped Public Cloud → PE PAYG modal (UX mismatch), and no internal links used it.

Code review fixes

  • Listener leak in DeferredLoadTrigger.__deferUntilInteraction timer path — flush listeners stayed attached if fallback timer fired before any interaction.

Commits

  1. Reduce pricing-page calculator INP on mobile
  2. Defer YourGPT widget until interaction on the current page
  3. Eager-load YourGPT widget for users who actually opened chat
  4. Lazy-load ThingsBoard modal calculators
  5. Lazy-load inline TBMQ calculators
  6. Delegate pricing-page click handlers from forEach to single root listener
  7. Defer pricing tooltip binds + ?faqSection= handler to idle time
  8. Replace JS-positioned segment highlight with per-tab CSS background
  9. Fix perpetual calculator CTA scrolling page to top on click
  10. Sync URL-driven pricing state from first paint via pre-paint inline script
  11. Remove URL-driven calculator open feature
  12. Fix listener leak in __deferUntilInteraction timer path
  13. Sync ?solution= billing state in pre-paint inline script

Test plan

  • pnpm build:fast passes
  • pnpm lint:linkcheck passes
  • /pricing/ cold load: no calc-*.js chunks in network waterfall until "Estimate your cost" is clicked
  • All product/sub-tab switching, billing/region toggles, top-up accordions, plan-card CTAs behave identically
  • FAQ click → expands; [data-open-calc] links open the right calculator
  • Deep-link /pricing/#faq-foo scrolls + expands correct FAQ + activates correct product/sub-tab synchronously
  • Deep-link /pricing/?product=thingsboard-pe&solution=pe-perpetual paints with Perpetual billing toggle on first frame — no flash of PAYG cards
  • Tooltips appear on hover (first hover may have ~50 ms delay before idle binds complete)
  • YourGPT widget appears after first interaction on a fresh tab; appears immediately on subsequent navs in the same session if user previously opened the chat
  • View Transitions: //pricing//about/ → back — calculators still openable, no duplicate listeners

rusikv added 13 commits May 26, 2026 18:41
Slider drag fired continuous `input` events that each rebuilt the full
results-panel HTML, re-read two CSS custom properties via
`getComputedStyle`, and re-bound a click listener per addon button.
On mid-range Android this pushed the worst interaction past the 200 ms
INP threshold (field: 220 ms).

- Cache `--sl-color-text-accent` / `--sl-color-gray-5` once in
  `sliderProgress`; invalidate via MutationObserver on `data-theme`.
  State lives on `window` so the `is:inline` script can inline once per
  modal instance without redeclaration errors.
- rAF-batch `calculate()` in all six calculators so continuous input
  events (slider drag, typing) trigger at most one rebuild per frame.
  Final-state events (`change`, `blur`) still commit immediately.
- Replace per-render `[data-enable-addon]` listener rebinding in
  TbPayg and TbPrivateCloud calculators with a single delegated click
  listener on the results container.
The widget injects its own Google Fonts CSS (Inter @ 5 weights from
fonts.gstatic.com). PageSpeed's critical request chain on /pricing
flagged it as a 2.97 s LCP-blocking dependency, even though we already
defer the widget script via `__deferOnInteraction`.

Root cause: `__deferOnInteraction` short-circuits to immediate execution
when `sessionStorage['tb:user-engaged']` is set — a flag flipped by the
*first* pointerdown / keydown / scroll / touchstart anywhere on the
site. So once a returning visitor scrolls one page, every subsequent
navigation in the session loads the chat widget (and its fonts) on the
critical path.

- Add `window.__deferUntilInteraction` — same shape, but ignores the
  session-engagement shortcut. Always waits for an interaction on the
  current page (or the fallback timer).
- Switch `YourGptWidget` to the stricter API, keeping the existing
  10 s fallback so the widget still eventually appears even for users
  who don't interact at all.

`__deferOnInteraction` is unchanged for everything else (analytics,
prefetch) — those are cheap enough that the engagement shortcut is
still the right default.
Previous commit defers the widget until interaction on the current
page (or its 10 s fallback). Trade-off: returning visitors who used
the chatbot once now wait several seconds on every subsequent nav
before the bubble re-appears.

Track a stronger intent signal: set `sessionStorage['tb:chatbot-opened']`
the first time the user clicks anywhere inside the widget. The detector
is a one-shot capture-phase `pointerdown` listener on document, scoped
to `#yourgpt-chatbot, [class*="ygpt-"], [class*="yourgpt-"]` — covers
regular DOM and Shadow DOM hosts (composed events retarget at the
shadow boundary).

On subsequent page navs:
- If the flag is set → eager `__deferOnInteraction` path; bubble
  appears immediately.
- Otherwise → strict `__deferUntilInteraction` path; keeps Inter
  off the LCP-critical chain for first impressions.

A user who scrolled the homepage but never opened chat doesn't qualify
as "wants chat pre-warmed." A user who actually opened the panel does.
Lighthouse's "Minimize main-thread work" diagnostic on /pricing/ flagged
~1.4 s of script evaluation. Three modal calculators
(TbPayg / TbPrivateCloud / TbPerpetual) totalling ~68 KiB of JS were
eagerly downloaded + parsed on every nav, even though most users never
open them. Their _ric() wrapper only deferred *execution*, not download.

Extract each calculator's body into a tree-shakeable ESM module under
src/scripts/pricing/. The .astro component now renders the modal HTML
at SSR time and ships a tiny inline loader (~700 B) that defines
window.openTbXCalc as an async function. First click dynamically imports
the chunk on demand; subsequent clicks reuse the memoised promise.

When the URL has ?calculator= and points at the relevant sub-tab, the
loader kicks off the import during page-load so the auto-open path
doesn't race the network.

Each module is idempotent (`if (openImpl) return`) so the loader's
astro:page-load re-installation across View Transitions does not
re-bind listeners against the persisted modal DOM.
Three inline TBMQ calculators (PAYG / Perpetual / Private Cloud,
~36 KiB JS combined) previously downloaded and parsed on every /pricing/
nav even though their init was already gated on the TBMQ product tab
becoming active. Extract each body into `src/scripts/pricing/calc-tbmq-*.ts`
and replace the component <script> with a tiny loader that listens for
`pricing:product-activated` (dispatched from `activateProductTab()` in
pricing/index.astro on every init, including URL-driven bootstraps) and
dynamically imports the module only when the detail is 'tbmq'.

The listener is registered once per document lifetime (guarded with a
window flag), so View-Transition navigations across /pricing/ don't
stack duplicates on the persistent document. Each module's init is
idempotent via `c.dataset.inited`, so re-invocations from repeated
tab activations are no-ops.

While extracting, also delegate the `[data-enable-mq-addon]` and
`[data-enable-mqpc-addon]` click handlers (previously re-bound per
calc()) — same pattern as the modal calculators in 279f60f70.
…ener

Previously `initPricing()` (which re-runs on every `astro:page-load`)
bound click listeners individually across ~10 selector classes, calling
`forEach($$('.X'))` for each:

  .product-tab, .sub-tab, .sub-tab-info, .plan-feature-info,
  .addon-tooltip-trigger, .comparison-tooltip-trigger,
  .upsell-card-btn, .region-toggle-label, .billing-toggle-label,
  .plan-card-cta[data-product-id][data-plan-id], .topup-header

Together those `forEach` loops walked hundreds of nodes (plan cards
× CTAs, comparison-table rows × info icons, etc.) on every nav,
attaching one `click` listener per match. Replace with a single
delegated click listener on `main.pricing-page` that dispatches via
`closest()` to the existing handler functions (`activateProductTab`,
`activateSubTab`, `scrollToFaqItem`, `getLicense`, accordion toggle,
upsell switch). Label-clicks (`.region-toggle-label` /
`.billing-toggle-label`) flip the sibling checkbox and dispatch a
`change` event, which the per-checkbox change handler still in place
picks up — no behaviour change.

Lifecycle: an AbortController-stored-on-window is reset each
`initPricing()` run. View Transitions keep the document alive across
navigations, so a simple "register once" guard would freeze the
listener to the first closure's handler functions — but those
functions mutate per-closure state (`activeProduct`, `activeSubTab`).
AbortController gives us "exactly one listener at a time, always the
current closure": each run aborts the previous listener and registers
a new one bound to the current closure.

Tooltip mouseenter/mouseleave bindings stay per-element for now
(mouseenter doesn't bubble cleanly) — they'll move to a deferred
`requestIdleCallback` in a follow-up. The document-level FAQ
delegation (`_pricingClickInit` block) is unrelated and untouched.
Four `forEach($$('.X')).bind(...)` loops attached `mouseenter` /
`mouseleave` listeners to every tooltip trigger on the page
(`.sub-tab-info`, `.plan-feature-info`, `.addon-tooltip-trigger`,
`.comparison-tooltip-trigger`) — potentially hundreds of nodes across
plan cards, comparison-table rows, and addon cards — synchronously on
every `initPricing()` run. None of this work blocks the user from
reading the page; it just enables tooltips on hover.

Wrap all four binds in a single `requestIdleCallback(bindAllPricingTooltips, { timeout: 1500 })`
(with `setTimeout(cb, 200)` fallback for Safari). `mouseenter` doesn't
bubble cleanly enough to fully delegate; the idle defer is the next
best move. First hover within ~50 ms of page-load may show a tooltip
slightly late — acceptable trade-off.

Also defer `?faqSection=` URL-param handling (activates a category
tab below the fold) to idle.

PRESERVED: the hash-based deep-link handler (`/pricing/#faq-id`) stays
SYNCHRONOUS — those URLs must scroll to the correct item without
jank. Comment in code calls this out so future edits don't move it.
The previous design used a single `.segment-highlight` element that JS
positioned by reading two `getBoundingClientRect()`s after class writes
in `moveHighlight()`. That pattern caused a forced reflow per
`activateProductTab` / `activateSubTab` call, flagged by PageSpeed
under "Forced reflow" (243 ms unattributed on /pricing/ mobile).

Replace with the same per-tab `.active` background pattern that mobile
already used — applied to all viewports. The cross-tab slide animation
is lost; instead the active tab cross-fades its background (0.25 s).
Static visual is unchanged: same white pill on the active tab.

Cleanup in pricing/index.astro:
- Remove moveHighlight() and initAllHighlights() (~17 lines)
- Remove all three call sites in activateProductTab() / activateSubTab()
- Remove the bottom-of-init bootstrap + resize listener
- Remove window._pricingInitAllHighlights / _pricingResizeInit state

Component changes:
- ProductTabs: drop `<div class="segment-highlight">`, drop the
  .segment-highlight CSS, add background + box-shadow to
  .segment-tab.active. Mobile keeps its outlined-button style with an
  explicit box-shadow override.
- ProductSubTabs: drop the highlight div; mobile media query keeps the
  rounded-corner override but inherits the active background.
CommunityEditionCard renders `<a href="#" onclick="...">` when the
data provides `ctaOnclick`. That worked when the inline onclick was
synchronous — by the time the browser tried to follow href="#" the
calculator modal had already opened and covered the page.

After commit 279f60f70 lazy-loaded the modal calculators, the inline
onclick became async (returns a Promise from `import()`). The browser
follows href="#" *before* the modal opens, scrolling the page to top
visibly. Reported on the Perpetual calculator (only caller wired this
way: `tbPerpetualHero.ctaOnclick = 'window.openTbPerpCalc?.()'`).

Prepend `event.preventDefault();` to the inline onclick in the
component so any future ctaOnclick caller is safe by default.
Same fix applied to the secondary CTA path.
…cript

/pricing/ is a statically generated page; its HTML contains every
product / sub-tab section and the SSR-default `.active` classes hard-
coded to the build-time defaults (`thingsboard` product, `thingsboard-cloud`
sub-tab). Visitors arriving via a deep-link URL like
`/pricing/?product=thingsboard-pe` saw the default tabs highlighted
and the default plan cards painted (invisible via opacity:0) until
`initPricing()` ran post-parse and swapped state — LCP waited on JS.

Add a tiny inline `<script is:inline>` immediately after the two
`.pricing-product-content` blocks (so all `.product-tab`, `.sub-tab`,
`.pricing-section`, and `.pricing-product-content` nodes are already
in the DOM) that mirrors the URL→state logic from initPricing() and:

- Toggles `.active` on the right `.product-tab`
- Toggles inline `display` on the two `.pricing-product-content` divs
- Inside the visible product, toggles `.active` on the right `.sub-tab`
- Toggles `.active` / `.visible` and inline `display` on each
  `.pricing-section`

Runs synchronously during body parsing, before first paint. Result:
deep-link URLs paint the correct tabs + correct content immediately,
with no JS-bundle-wait gap. When initPricing() runs at the bottom of
body it re-applies the same logic against the now-correct state —
idempotent for state, no flicker.

Why a body-end inline script instead of a head script + CSS attribute
selectors: that approach would require enumerating ~25 CSS rules to
cover sub-tab .active styling (color, background, box-shadow, icon
color, info-icon color) across all 7 sub-tab values, and tooling
around handing off from CSS-attribute-driven state to JS-class-driven
state on first interaction. A single inline script is cleaner.

Graceful degradation if JS fails: visitor sees SSR-default state
(thingsboard / public cloud) regardless of URL — same as before this
change, so no regression.
The `?calculator=…` URL param dispatched to a `calcMap` that opened
PAYG / Private Cloud calculators based on the active sub-tab. The
contract was inconsistent with the legacy site (which used three
separate flag-style params — `?calculator`, `?calculatorPayg`,
`?calculatorPerpetual` — each clicking a specific button) and the
default-fallback mapped `thingsboard-cloud` to the PAYG calc even
though the Public Cloud section has no calculator of its own —
landing on Public Cloud with a PE PAYG modal popping over it.

No internal links in this repo used `?calculator=…`; no redirects
existed for the legacy variants. Removing the receiver cleans up
the dead URL contract without breaking anything that wasn't already
broken at migration time.

Drops the calcMap block in pricing/index.astro plus the matching
deep-link prefetch branches in the three modal calculator loaders.
The "Estimate your cost" buttons and FAQ `data-open-calc` links
still open calculators as before — only the URL-driven path is gone.
When a returning visitor (sessionStorage `tb:user-engaged` set)
called `__deferUntilInteraction`, the function attached four `flush`
listeners to `window` (`pointerdown`, `keydown`, `scroll`,
`touchstart`) so the first real interaction on the new page would
run the pending callbacks. The `flush` body removed all four
listeners on first event, but the fallback `setTimeout` path only
called `runOne(cb)` — leaving the four listeners attached forever
(well, until the next hard navigation).

Low impact (passive listeners that no longer have work to do), but
a real leak. Wrap the cleanup in a shared closure and call it from
both the flush and timer paths so listeners are always removed
after the callbacks run.
The body-end inline script that mirrors initPricing()'s URL→state
logic before first paint already covered `?section=` and `?product=`
(product tab + sub-tab + section visibility) but skipped `?solution=`.
Visitors arriving via `/pricing/?solution=pe-perpetual&…` saw the
SSR-default PAYG plan cards flash briefly before initPricing() ran
and flipped the billing toggle to perpetual.

Extend the inline script to also read `solution`, derive
`activeBilling`, and — when perpetual — mirror the full billing
visual state pre-paint: checkbox `checked = true`, swap display on
`[data-billing-content]` groups, toggle `.active` on the right-side
label, and update text on `[data-billing-heading]` / `[data-billing-
subtitle]` from their `data-perpetual-text` attributes. initPricing()
later re-applies the same state — idempotent, no flicker.
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