perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430
Open
rusikv wants to merge 13 commits into
Open
perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430rusikv wants to merge 13 commits into
rusikv wants to merge 13 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
window._sliderColors, delegate per-element click listeners.import()— fetched only on first open / onpricing:product-activatedfor inline TBMQ.tb:chatbot-opened).<div class="segment-highlight">with per-tab CSS background, eliminatinggetBoundingClientRect()reads after class writes.requestIdleCallbackso they don't compete with the LCP paint.UX
<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.openTbPerpCalcraced thehref="#"follow).?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
DeferredLoadTrigger.__deferUntilInteractiontimer path —flushlisteners stayed attached if fallback timer fired before any interaction.Commits
?faqSection=handler to idle time__deferUntilInteractiontimer path?solution=billing state in pre-paint inline scriptTest plan
pnpm build:fastpassespnpm lint:linkcheckpasses/pricing/cold load: nocalc-*.jschunks in network waterfall until "Estimate your cost" is clicked[data-open-calc]links open the right calculator/pricing/#faq-fooscrolls + expands correct FAQ + activates correct product/sub-tab synchronously/pricing/?product=thingsboard-pe&solution=pe-perpetualpaints with Perpetual billing toggle on first frame — no flash of PAYG cards/→/pricing/→/about/→ back — calculators still openable, no duplicate listeners