Skip to content

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

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

perf(pricing): reduce INP, lazy-load calculators, defer YourGPT widget#430
rusikv wants to merge 21 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.
Copy link
Copy Markdown
Member

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/components/Pricing/TbPaygCalculator.astro Outdated
Comment thread src/components/Pricing/TbPaygCalculator.astro Outdated
Comment thread src/components/Pricing/TbmqPaygCalculator.astro Outdated
Comment thread src/components/DeferredLoadTrigger.astro Outdated
Comment thread src/pages/pricing/index.astro Outdated
rusikv added 2 commits May 27, 2026 14:51
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.
Copy link
Copy Markdown
Member

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread src/pages/pricing/index.astro Outdated
const productParam = p.get('product');
const solutionParam = p.get('solution');

// NOTE: this URL→state resolution must stay in sync with the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/components/YourGptWidget.astro Outdated
Comment on lines +19 to +21
const start = () => (window.requestIdleCallback || ((cb) => setTimeout(cb, 200)))(injectWidget);
if (document.readyState === 'complete') start();
else window.addEventListener('load', start, { once: true });
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  • GtmHead registers __deferOnInteraction(loadGtm) with the default 2500 ms fallback.
  • The old YourGPT used __deferOnInteraction(loadWidget, 10000) — a 10000 ms fallback.
  • DeferredLoadTrigger runs pending callbacks in registration order, and GtmHead is mounted before YourGptWidget in ThirdPartyScripts.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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@vvlladd28 vvlladd28 self-requested a review May 29, 2026 08:38
rusikv added 2 commits May 29, 2026 14:55
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.
Comment thread src/components/YourGptWidget.astro Outdated
Comment on lines +12 to +30
// 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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
Copy link
Copy Markdown
Member

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

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

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 setBillingupdateFaqVisibilityupdateUrl), 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:1332document.addEventListener('astro:page-load', initPricing) (unchanged line) plus the new _pricingDelegCtl AbortController machinery and several new comments ("View Transitions keep the document alive", the TBMQ loader's "the script re-runs on each astro:page-load") all assume the SPA router is active. I couldn't find a <ClientRouter /> / viewTransitions anywhere in src/, astro.config.ts, or the Starlight config — so on these BaseLayout pages astro:page-load never fires and initPricing() 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) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread src/pages/pricing/index.astro Outdated
// 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Comment thread src/pages/pricing/index.astro Outdated
const productParam = p.get('product');
const solutionParam = p.get('solution');

// NOTE: this URL→state resolution must stay in sync with the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

rusikv added 3 commits May 29, 2026 15:50
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.
Copy link
Copy Markdown
Member

@vvlladd28 vvlladd28 left a comment

Choose a reason for hiding this comment

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

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, but src/components/YourGptWidget.astro isn't in this PR's changed files, and __deferUntilInteraction doesn'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, so astro:page-load never fires on these BaseLayout marketing pages. That makes removing document.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:1579 still registers an astro:page-load listener — harmless dead weight, pre-existing, not introduced here.)
  • src/pages/pricing/index.astro:400 & :1295 — both the pre-paint script and initPricing() iterate every [data-billing-toggle] when activeBilling === 'perpetual', which also flips the Private Cloud toggle (tb-pc-billing) to its "annual" state on a ?solution=pe-perpetual deep-link. Impact is minor (PC has no data-billing-group content elements, so nothing is hidden, and PC isn't the deep-link target) and the behavior is pre-existing in initPricing() — 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-invokes import(...) + initMqpCalc() on every pricing:product-activated for tbmq, without memoizing the promise the way the modal openers do (p ??= import(...)). Harmless since the module is cached and initMqpCalc() 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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'.

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.

2 participants