perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430
perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430rusikv wants to merge 21 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.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 19 changed files in perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget. The bulk is a careful extract-to-module refactor of six pricing calculators (logic preserved verbatim), plus real perf wins: rAF-batched slider input, lazy import() for the three modal calculators, pre-paint URL state sync, segment-highlight replaced with per-tab CSS, single delegated click listener replacing 10+ per-element loops, and the new __deferUntilInteraction API for the YourGPT widget. Left 5 inline comments — most are minor robustness asks; one (leaked dev path) is a clear cleanup before merge.
This is an auto-generated review. Findings may contain errors — please verify before applying changes.
Reverts the interaction-gated approach (the defer-until-interaction + eager-for-chatbot-openers logic). Loading YourGPT on each page was the intended behavior, so gate the injection on the load event + next idle instead: the widget appears promptly on every nav without waiting for a user interaction, and stays off the initial render path. Removes the now-unused __deferUntilInteraction helper from DeferredLoadTrigger (only YourGptWidget consumed it; __deferOnInteraction stays — GTM and the GitHub-stars button still use it). Note: this does not remove the widget's Inter font from Lighthouse's critical-request-chain diagnostic — simulated throttling scales the fast-lab timeline so any auto-load lands in the measured window. Accepted because the font is widget-only; the site uses a system font stack, so it never blocks the page's actual LCP.
…leanup - Remove stale dev-local plan-file path from TbPaygCalculator comment. - Make the three modal calculators (PAYG / Perpetual / Private Cloud) recover from a failed dynamic import: reset the memoised promise on rejection so a flaky / blocked / stale-hash chunk fetch retries on the next click instead of wedging the modal until a page refresh, and wrap the open in try/catch so the async onclick no longer leaks an unhandled rejection. - Add .catch() to the three inline TBMQ loaders so a failed chunk fetch stays silent rather than logging an unhandled rejection. - Match the tbmq idiom between the pre-paint inline script and initPricing() (indexOf === 0 → ?.startsWith) and add reciprocal keep-in-sync comments so the duplicated URL→state logic doesn't drift.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 19 changed files in perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget. The bulk is a mechanical move of the six calculators' JS from inline <script> blocks into lazy-loaded ~/scripts/pricing/calc-*.ts modules — I spot-checked the moved bodies (init guards, openImpl/data-inited idempotency, calculate() at init tail, slider math) and they preserve the original behavior. The genuinely new logic — delegated click handler, CSS-only segment highlight, _sliderColors cache + theme MutationObserver, pre-paint state sync, event.preventDefault() CTA fix — is sound: the delegated billing/region label clicks dispatch synthetic change that the existing handlers pick up (and the labels are <span>s, so there's no native double-toggle), and no dangling segment-highlight/moveHighlight/?calculator= references remain. Two observations left inline.
This is an auto-generated review. Findings may contain errors — please verify before applying changes.
| _ric(() => initTbPaygCalc()); | ||
| document.addEventListener('astro:page-load', () => _ric(() => initTbPaygCalc())); | ||
| installOpener(); | ||
| document.addEventListener('astro:page-load', installOpener); |
There was a problem hiding this comment.
Worth confirming the View-Transition story here. The repo has no <ClientRouter /> (BaseLayout doesn't render one), so astro:page-load never actually fires — this re-install is currently dead. More importantly, if View Transitions are enabled later (the PR test plan exercises / → /pricing/ → /about/ → back), these modal calculators won't be VT-safe: p and openImpl in calc-tb-payg.ts are module-scoped and never reset, so after a navigation openTbPaygCalc() skips re-init (if (openImpl) return) and drives the previous page's detached #tb-payg-calc node — the modal would silently fail to open. The inline TBMQ calculators avoid exactly this by re-initialising against the fresh DOM via the data-inited container guard. Consider either dropping the dead astro:page-load wiring, or making the three modal calcs VT-safe the same way for consistency (same pattern in TbPerpetualCalculator.astro / TbPrivateCloudCalculator.astro).
There was a problem hiding this comment.
Fixed in 14565ca — dropped the dead astro:page-load listeners from TbPaygCalculator.astro, TbPerpetualCalculator.astro, and TbPrivateCloudCalculator.astro. Verified no ClientRouter / ViewTransitions reference exists in src/, astro.config.ts, or the Starlight integration config (Starlight's docs sidebar persists via a sessionStorage + pre-paint custom-element trick, not VT). Picked the deletion route over retrofitting the data-inited guard — VT isn't on the roadmap and removing the misleading dead code is preferable to adding speculative VT-safety scaffolding. If anyone wires up ClientRouter later they'll need to make these modules properly VT-safe, not just rely on a re-install hook that depends on module state surviving navigation.
| const productParam = p.get('product'); | ||
| const solutionParam = p.get('solution'); | ||
|
|
||
| // NOTE: this URL→state resolution must stay in sync with the |
There was a problem hiding this comment.
This pre-paint resolver duplicates ~40 lines of the URL→state logic (param parsing, sectionMap, the perpetual billing flip, the product/sub-tab/section activation) from initPricing() below, and the two copies have to be hand-kept in sync. The NOTE comments in both places help, but drift here fails silently — a future tweak to the billing flip or sectionMap in only one block won't break any test, it'll just reintroduce the FOUC this is meant to prevent (or paint a state that JS then corrects a frame later). Worth extracting the shared resolution into a single helper both the inline script and initPricing() call, so there's one source of truth.
There was a problem hiding this comment.
Acknowledged but not changing in this PR. The two URL→state copies are short (~40 lines), the NOTE comments at both ends explicitly call out the drift contract, and a shared helper that runs in both the inline pre-paint context AND inside initPricing()'s closure scope has its own coordination cost. Happy to revisit as a separate refactor if the duplication grows or drift bites — for now leaving the explicit-contract approach in place.
vvlladd28
left a comment
There was a problem hiding this comment.
Follow-up: YourGPT load-ordering regression
One more observation on the YourGptWidget change — left inline.
This is an auto-generated review. Findings may contain errors — please verify before applying changes.
| const start = () => (window.requestIdleCallback || ((cb) => setTimeout(cb, 200)))(injectWidget); | ||
| if (document.readyState === 'complete') start(); | ||
| else window.addEventListener('load', start, { once: true }); |
There was a problem hiding this comment.
This moves the widget off the shared __deferOnInteraction trigger onto its own load + requestIdleCallback path, and in doing so it drops an intentional load ordering. The original deferral wasn't only about keeping the script/font off the critical path — it also guaranteed the chatbot loads after analytics:
GtmHeadregisters__deferOnInteraction(loadGtm)with the default 2500 ms fallback.- The old YourGPT used
__deferOnInteraction(loadWidget, 10000)— a 10000 ms fallback. DeferredLoadTriggerruns pending callbacks in registration order, andGtmHeadis mounted beforeYourGptWidgetinThirdPartyScripts.astro.
So in both the interaction and the no-interaction case, GTM/analytics loaded first. With the new load+idle trigger, on a no-interaction page load the widget can fire before GTM's 2.5 s fallback, so analytics is no longer guaranteed to load first.
Suggest reverting to (window.__deferOnInteraction || ((cb) => cb()))(loadWidget, 10000) — it keeps both the off-critical-path deferral and the analytics-first ordering.
There was a problem hiding this comment.
Fixed in c6f3c18 — restored to the pre-PR pattern __deferOnInteraction(loadWidget, 10000) where loadWidget wraps injectWidget in requestIdleCallback. Surfaced a second bug along the way: when DeferredLoadTrigger.astro:32's fired short-circuit runs the callback synchronously (tb:user-engaged set by a prior page), the body-append fires inside <head> parsing — the rIC bounce dodges that. Expanded the inline comment to document both invariants (analytics-first ordering + body-safety) so this doesn't get reverted blindly again.
Commit 2e1abb1 moved the widget off the shared deferred-load queue onto a `load + requestIdleCallback` trigger. PR thingsboard#430 review surfaced two problems with that: 1. Load ordering regression. `GtmHead` registers `__deferOnInteraction(loadGtm)` (default 2500ms fallback) and is mounted before this widget in ThirdPartyScripts.astro, so DeferredLoadTrigger runs GTM first in both interaction and no-interaction paths. The `load + idle` trigger could fire before GTM's 2500ms fallback on quiet pageviews, breaking the analytics-first invariant the rest of the third-party stack depends on. 2. The `injectWidget` callback hit `document.body.appendChild` on null when `DeferredLoadTrigger.astro:32`'s `fired` short-circuit ran the callback synchronously inside <head> parsing (triggered when a prior page in the session set the `tb:user-engaged` flag). The original code dodged this by wrapping the appendChild in a `requestIdleCallback` bounce so it always deferred until idle — which is necessarily after DOMContentLoaded. Restoring the exact pre-PR pattern: `loadWidget` does the rIC bounce around `injectWidget`, and `__deferOnInteraction(loadWidget, 10000)` schedules it through the shared queue. Expanded the inline comment to spell out both invariants so the next revert doesn't reintroduce them.
TbPaygCalculator / TbPerpetualCalculator / TbPrivateCloudCalculator each registered a document-level `astro:page-load` listener to reinstall their `window.openTb*Calc` opener after View Transitions navigations. But `astro:page-load` only fires when Astro's `<ClientRouter />` is enabled, and it isn't anywhere on this site (verified: no ClientRouter / ViewTransitions reference in src/, astro.config.ts, or the Starlight integration config — Starlight's docs sidebar persists via a sessionStorage + pre-paint trick, not VT). So these listeners never fire. Worse, they imply the modal calculators are VT-safe when they aren't: `let openImpl` in `src/scripts/pricing/calc-tb-payg.ts` (and the perp / pc siblings) is module-scoped, and the `if (openImpl) return` guard in `openTb*Calc()` means a post-VT-nav click would mutate the previous page's detached modal node and the visible modal would silently stay hidden. The TBMQ inline calculators avoid this with their `data-inited` container guard. Removing the misleading dead code rather than retrofitting the `data-inited` pattern — VT isn't on the roadmap, and the rest of the site (including docs) is plain MPA. If anyone enables ClientRouter later, they'll need to make these modules VT-safe properly, not just register a re-install handler that depends on module state surviving navigation.
| // Defer the widget through the shared `__deferOnInteraction` queue with a | ||
| // 10000 ms fallback. Two things matter here: | ||
| // | ||
| // 1. We register `loadWidget`, not `injectWidget` directly. `loadWidget` | ||
| // wraps the appendChild in `requestIdleCallback`. Critical: if the user | ||
| // already engaged elsewhere in this session, `DeferredLoadTrigger` | ||
| // treats `__deferOnInteraction` as fired and runs the callback | ||
| // SYNCHRONOUSLY at register-time — which is inside <head> parsing, | ||
| // before <body> exists. The rIC bounce defers the appendChild to idle | ||
| // time, by which point the body is parsed. | ||
| // | ||
| // 2. Going through the deferred queue (rather than gating on `load` + idle | ||
| // directly) preserves the load-ordering invariant against analytics: | ||
| // `GtmHead` registers `__deferOnInteraction(loadGtm)` (default 2500 ms | ||
| // fallback) and is mounted before this widget in `ThirdPartyScripts`, | ||
| // so GTM fires first in both the interaction and no-interaction paths. | ||
| // | ||
| // The widget's Inter font isn't visible in Lighthouse's critical chain | ||
| // because the site's own CSS uses a system font stack. |
There was a problem hiding this comment.
Please remove the comment that is not clear in production mode
The previous commit restored the pre-PR trigger logic but left behind a 17-line explanatory comment. The widget block is now byte-for-byte identical to the upstream/main original (no comment), keeping the net PR change to this file a true no-op.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 19 changed files in perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget. The bulk of the diff is a clean mechanical extraction of the six calculator implementations out of their .astro <script> blocks into lazy-imported src/scripts/pricing/calc-*.ts modules. That part looks solid: the modal openers memoize the dynamic import() and reset the memo on failure for retry, init is idempotent (openImpl guard for modals, dataset.inited for the inline TBMQ calcs), the delegated click handler preserves the original side effects (e.g. billing-label clicks dispatch change, which still runs setBilling → updateFaqVisibility → updateUrl), the perpetual-CTA event.preventDefault() fix is applied in the component that actually renders that CTA (CommunityEditionCard), and the segment-highlight removal is safe because both tab components declare their styles is:global, so the shared .segment-tab.active rule still reaches the sub-tabs. Left 3 comments inline — one possible deep-link init gap, one question about dead View-Transitions machinery, and one maintainability nit.
Additional findings
These are about behavior that spans the file rather than a single changed line:
- src/pages/pricing/index.astro:1332 —
document.addEventListener('astro:page-load', initPricing)(unchanged line) plus the new_pricingDelegCtlAbortController machinery and several new comments ("View Transitions keep the document alive", the TBMQ loader's "the script re-runs on eachastro:page-load") all assume the SPA router is active. I couldn't find a<ClientRouter />/viewTransitionsanywhere insrc/,astro.config.ts, or the Starlight config — so on theseBaseLayoutpagesastro:page-loadnever fires andinitPricing()runs exactly once per full load. If that's correct, the abort/re-run handling and re-dispatch guards are dead defensive code (harmless, but the comments are misleading). Worth confirming whether View Transitions are intended to be enabled — see the inline note at line 547.
This is an auto-generated review. Findings may contain errors — please verify before applying changes.
| // on the persistent document. | ||
| if (!(window as any)._tbmqPaygLoaderInited) { | ||
| (window as any)._tbmqPaygLoaderInited = true; | ||
| document.addEventListener('pricing:product-activated', ((e: CustomEvent) => { |
There was a problem hiding this comment.
This (and the identical loaders in TbmqPerpetualCalculator / TbmqPrivateCloudCalculator) initializes the inline TBMQ calculators only via a one-shot pricing:product-activated listener. On a deep-link that makes TBMQ the initial active product (?product=tbmq-payg, ?section=tbmq-options), initPricing() dispatches that event once at module-eval time (activateProductTab('tbmq')). That only works if this loader's <script> has already executed and registered the listener before the page's initPricing() script runs — i.e. it depends on the bundler's script-execution order. If initPricing() happens to run first, the initial 'tbmq' event is missed and the calculator stays uninitialized (dead sliders / no totals) until the user re-clicks the tab. Could you confirm a TBMQ deep-link initializes the calculator on first paint? If it's order-dependent, a more robust fix would be to have the loader also check the currently-active product on registration (e.g. read the .product-tab.active/visible content and import immediately if it's already tbmq), rather than relying solely on catching the one-time event.
| // own closure to be the one taking action. AbortController gives us | ||
| // "exactly one listener at a time, always the current closure". | ||
| { | ||
| const prevCtl = (window as any)._pricingDelegCtl as AbortController | undefined; |
There was a problem hiding this comment.
The _pricingDelegCtl abort-the-previous-controller pattern (and the comment justifying it — "initPricing() re-runs on every astro:page-load… View Transitions keep the document alive") only does anything if initPricing actually runs more than once. I couldn't find <ClientRouter /> / View Transitions enabled anywhere in the project, and these pages use BaseLayout, so astro:page-load never fires and initPricing() runs once per full page load. If that's right, prevCtl?.abort() is never reached and this could just be a plain addEventListener on main.pricing-page. Not harmful as-is, but is View Transitions something that's planned here, or is this machinery (plus the astro:page-load listener at the bottom) leftover from an assumption that isn't true on these pages?
| const productParam = p.get('product'); | ||
| const solutionParam = p.get('solution'); | ||
|
|
||
| // NOTE: this URL→state resolution must stay in sync with the |
There was a problem hiding this comment.
This pre-paint block re-implements ~60 lines of the URL→state resolution that initPricing() also does (sectionMap, param parsing, billing-toggle flip, tab/section activation), kept in sync only by the cross-referencing comments. It's a real correctness trap: a future change to param names, the sectionMap, or the billing markup has to be made in both places or deep-link first-paint silently regresses (the exact flash this block exists to prevent). Since the duplication is forced by the inline-vs-module split, consider at least factoring the shared resolver into a small function defined once in an is:inline script and called from both, so there's a single source of truth for the param→state mapping.
The three inline TBMQ calculator loaders initialised solely via a one-shot 'pricing:product-activated' listener. On a deep-link landing directly on TBMQ (?product=tbmq-payg, ?section=tbmq-options), initPricing() dispatches that event once at module-eval time. Both the loader and the initPricing() dispatcher are hoisted module scripts with no guaranteed execution order, so initPricing() could fire the event before the listener was attached, leaving the calculator uninitialised (dead sliders) until the user re-clicked the tab. Each loader now also checks .product-tab.active on registration and loads immediately if TBMQ is already active. The pre-paint inline script sets .product-tab.active during HTML parse (before any module runs), so the deferred loader always sees the correct deep-linked state. The init functions are idempotent (container data-inited guard), so the two paths can't double-init. Addresses PR thingsboard#430 review comment on TbmqPaygCalculator.astro.
This project has no ClientRouter / View Transitions, so initPricing() runs
exactly once per full page load and the document is never reused across
navigations. The re-run bookkeeping that assumed otherwise was dead:
- The delegated click listener wrapped its registration in an AbortController
(prevCtl?.abort() + _pricingDelegCtl) purely to drop a previous run's
listener on re-init. prevCtl?.abort() was never reachable. Replaced with a
plain addEventListener on the persistent main.pricing-page root.
- Dropped the document.addEventListener('astro:page-load', initPricing)
listener — astro:page-load never fires without a ClientRouter (same dead
wiring already removed from the modal calculators in 14565ca).
The _pricingScrollInit / _pricingClickInit once-guards register document/
window-level listeners and still run correctly (registering once), so they're
kept as idempotency safety; only their stale 'initPricing re-runs on every
astro:page-load' comments were corrected.
Addresses PR thingsboard#430 review comment on index.astro:547.
The pre-paint is:inline block and initPricing() each re-implemented the same param→state resolution (section/product/solution parsing, sectionMap, the tbmq detection and the solution→billing mapping), kept in sync only by cross-referencing comments. A future change to a param name, the sectionMap, or the billing mapping in one copy would silently regress deep-link first paint (the exact FOUC the pre-paint block exists to prevent) with no test to catch it. Factor the pure resolution into window.__resolvePricingState(search), defined in the is:inline script (which runs during parse, before the deferred initPricing() module), and call it from both sites. Only the param→state mapping is shared; the DOM application stays separate in each caller since it legitimately differs (raw class toggling pre-paint vs activateProductTab()/ activateSubTab() with event dispatch in initPricing). initPricing keeps its own `params` (still used for ?faqSection=) and its `let` state vars (the tab and billing handlers reassign them as the user navigates). Addresses PR thingsboard#430 review comment on index.astro:365.
vvlladd28
left a comment
There was a problem hiding this comment.
Review summary
Reviewed 18 changed files in perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget. This is a careful, well-commented refactor and the core changes hold up: the delegated click handlers are behaviorally equivalent to the per-element listeners they replace (verified the region/billing-label <span>s don't double-fire with native <label> activation, and the PC billing checkbox does carry data-billing-toggle), the is:global styles make the segment-highlight→.active CSS refactor apply correctly to sub-tabs, the slider-color cache busts on the data-theme attribute the site actually toggles, and the lazy-load openers are idempotent with sensible retry-on-failure. Left 1 comment inline plus a few low-priority notes below.
Additional findings
Observations outside the inline-diff range or about the PR metadata:
- PR description vs. diff mismatch. The description's "Performance / LCP" section and "Code review fixes" describe deferring the YourGPT widget and a listener-leak fix in
DeferredLoadTrigger.__deferUntilInteraction, butsrc/components/YourGptWidget.astroisn't in this PR's changed files, and__deferUntilInteractiondoesn't exist in the tree (the function is__deferOnInteraction, and its only change here is an added comment). Either those commits landed separately or the description is stale — worth reconciling so reviewers/changelog aren't misled. - "View Transitions" test-plan item is moot. There is no
<ClientRouter />/<ViewTransitions />anywhere in the repo, soastro:page-loadnever fires on theseBaseLayoutmarketing pages. That makes removingdocument.addEventListener('astro:page-load', initPricing)correct and the rewritten "no ClientRouter" comments accurate — but the test-plan line "View Transitions:/→/pricing/→/about/→ back … no duplicate listeners" can't actually exercise a client-side transition (every nav is a full reload). (Separately,CalculatorModal.astro:1579still registers anastro:page-loadlistener — harmless dead weight, pre-existing, not introduced here.) src/pages/pricing/index.astro:400&:1295— both the pre-paint script andinitPricing()iterate every[data-billing-toggle]whenactiveBilling === 'perpetual', which also flips the Private Cloud toggle (tb-pc-billing) to its "annual" state on a?solution=pe-perpetualdeep-link. Impact is minor (PC has nodata-billing-groupcontent elements, so nothing is hidden, and PC isn't the deep-link target) and the behavior is pre-existing ininitPricing()— but the new pre-paint copy now applies it before first paint too. Scoping the loop to the self-managed toggle would be more precise.src/components/Pricing/TbmqPerpetualCalculator.astro:51(and the sibling inline TBMQ loaders) —loadCalc()re-invokesimport(...)+initMqpCalc()on everypricing:product-activatedfor tbmq, without memoizing the promise the way the modal openers do (p ??= import(...)). Harmless since the module is cached andinitMqpCalc()is idempotent, but it's inconsistent with the modal-opener pattern and does a (cheap) redundant init on each tab switch back to TBMQ.
This is an auto-generated review. Findings may contain errors — please verify before applying changes.
| let activeProduct = 'thingsboard'; | ||
| let activeSubTab: string | null = null; | ||
| let activeBilling: string | null = null; | ||
| ({ activeProduct, activeSubTab, activeBilling } = (window as any).__resolvePricingState( |
There was a problem hiding this comment.
initPricing() now hard-depends on window.__resolvePricingState, which is defined only by the separate pre-paint is:inline script near the top of <body>. The pre-paint block wraps its DOM work in try/catch, but this consumer has no fallback — if that inline script ever fails to run or throws (a CSP that blocks inline scripts, or a syntax error introduced into the block later), this destructuring throws and all of initPricing() aborts, leaving the entire pricing page non-interactive (tabs, toggles, accordions, calculators all dead).
Before this PR the param→state mapping was self-contained in initPricing(), so a failure here was impossible. Low probability given inline scripts are used elsewhere on the page, but the blast radius is the whole page — worth a guard, e.g. fall back to computing state locally when typeof window.__resolvePricingState !== 'function'.
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