Widget Load Sequence

Widget Load Sequence

How the storefront widget boots — from the server-rendered button to an open modal — and how we make the “Try it on” button instant and never dead-clickable.

Related: Widget States · Widget Components · Try-On Pipeline.


The scripts

FileRoleLoad
tryvio-theme.js (~6.2 KB min)Stage 1 — the only eager script. Finds each [data-tryvio-root] (armRoot), auto-places the button (findProductMediaHost), starts the config fetch (loadConfig) and pulls stage 2 in parallel, then hands off.Block "javascript" → Shopify emits it defer.
tryvio-boot.js (~8.1 KB min)Stage 2 — everything that can only run once the config landed: theme apply, sold-out gate, impression event, the modalConfig literal, the loading skeleton, the real click handler, the segmentation-context computation (#304) and the attention-cue arm (#325). Exposes window.__tryvioBoot.run(ctx, configPromise).Injected by stage 1, in parallel with the /api/proxy request.
tryvio-widget.js (~12 KB min)Image utils, shopper identity, window._TN.runModal / startPrefetch.Lazy — injected by stage 2’s ensureWidget() on hover/click.
tryvio-modal.js (~138 KB min)The modal UI. Exposes window._TN.openModal.Lazy — injected by tryvio-widget.js when the modal opens.
tryvio-collection.jsInjects the same button onto collection/home/product-block cards.defer.

The heavy modal is never on the critical path — it loads on click, behind a loading skeleton (.tryvio-modal.tryvio-loading-skeleton, built by stage 2’s buildSkeleton()).

Why stage 1 / stage 2 are separate files (#72)

Shopify caps an app block’s "javascript" asset at 10 KB minified (AssetSizeAppBlockJavaScript), and both blocks declare tryvio-theme.js — the single-file bootstrap had grown to 13.3 KB. Splitting at the await config boundary is free in wall-clock terms: the cross-origin /api/proxy call is the slow leg, and stage 2 is fetched off the same Shopify CDN at the same instant, so max(config, stage2) == config in practice. A click that lands while stage 2 is still in flight is queued on ctx.clicked and replayed by stage 2 — the same guarantee the old file gave for clicks that landed during the config fetch. If stage 2 cannot load, stage 1 degrades exactly like a failed config fetch — see Failure path (#410): an auto-placed launcher is hidden, a manual one is disabled with its hint, and a tryon_widget_error beacon records it.

Both scripts/deploy-widget.mjs and playwright-debug/e2e-suite.js fail hard if the minified tryvio-theme.js crosses 10 KB again — anything new belongs in tryvio-boot.js.


The problem: deferred script → a click gap

tryvio-theme.js is deferred, so it executes after HTML parse (at DOMContentLoaded). The button is painted much earlier (it’s server-rendered by tryvio-launcher.liquid). Between paint and w() wiring the click handler there is a window whose size = the script’s download+exec time (hundreds of ms on slow mobile). The old design hid this by keeping the button opacity:0 until w() ran and the /api/proxy config returned — i.e. the button only became clickable once fully wired. That removed dead clicks but also made the button flash hidden for a whole network round-trip.

The fix: reveal-first + an inline micro-bootstrap (B22)

The button is visible from the first paint and interactive immediately, with the heavy work behind it.

parse ─┬─ button painted (visible: in-stock) ─┬─────────────────────────► modal opens
       ├─ inline micro-bootstrap runs         │  (over the loading skeleton)
       │    ├─ config fetch kicked (eager) ────┼──► config ready
       │    └─ click listeners armed          │
       │                                       │
   [early click] ─► spinner + pending flag ────┘  (deferred tryvio-theme.js → stage 2 replays the click)

1. Liquid — reveal-first + sold-out (server side)

snippets/tryvio-launcher.liquid renders the button visible (no JS gate). It knows availability, so a sold-out product renders the button hidden server-side with .tryvio-product-button--soldout-hidden (CSS display:none) — matching the hideOnSoldOut default (ON), so it never flashes.

2. Inline micro-bootstrap — runs during parse

A tiny <script> inlined right after the button (so it runs before the deferred tryvio-theme.js):

  • Eager config prefetch — reads data-proxy-url, kicks the fetch now, stashes the promise on window.__tvCfg[url]. The config request is in flight from parse time, parallel with page load.
  • Catch early clicks — capture-phase pointerdown/click on the trigger. If the root is not yet bootstrapped (data-tryvio-bootstrapped !== "true"): preventDefault, add the existing --loading spinner (instant feedback), set data-tryvio-pending-open="1". No dead click.
  • Idempotent; de-dupes against the main bundle via data-tryvio-bootstrapped.

3. bootstrapRoot() (stage 1) + run() (stage 2) — reveal-first, eager reuse, replay

  • Reveal-first: the opacity:0 gate applies only to autoplace, so the launcher button is never hidden by JS.
  • Eager reuse: loadConfig(url) awaits window.__tvCfg[url] (the inline prefetch) before doing its own fetch; the 30 s sessionStorage cache is the second tier.
  • Replay: after stage 2 wires the real handler, if data-tryvio-pending-open === "1" or ctx.clicked (a click stage 1’s earlyClick caught while stage 2/the config were still loading), it clears the flag + spinner and dispatches the click → the modal opens. No dead click, at any point in the chain.
  • Sold-out reveal: on reveal it clears --soldout-hidden; so a store with hideOnSoldOut OFF shows the button on sold-out products (try-on still works; the modal surfaces the sold-out state — the cart-add is blocked by the B2 path).

window.__tvCfg is written by the inline bootstrap and read by the same-version bundle. An older cached tryvio-theme.js simply ignores it and does its own fetch — safe across widget versions.

4. Segmentation context + attention cue (#304 / #325)

Once kit.run has an enabled, revealed button it computes widgetCtx(root) once — device class, viewport bucket, page type, and button kind/tier/label/scale, see Analytics & ROI → Widget event context — and threads it through three places: the tryon_widget_impression payload, modalConfig.buttonContext (which rides the modal’s session POST as context, so the server stamps a normalized copy onto the server-emitted tryon_widget_click), and the tryon_modal_opened payload. This makes impression → click → open segmentable end to end, with no new table.

Immediately after — only once enabled === true and the trigger is revealed, never inside the #273 pre-config window — armAttentionCue(root, trigger) arms the button attention cue (#325): an IntersectionObserver (threshold 0.6) adds .tryvio-product-button--attn the first time the button enters the viewport, skipped when the merchant switch is off (data-tryvio-attention === "off") or the cue is already dismissed for this session. Card roots are armed separately, by tryvio-collection.js’s armCardCue at inject time. The cue is CSS-only (tryvio-theme.css) — zero bytes land in the 10 KB tryvio-theme.js budget (#72).


Failure path — config failed / stage 2 failed (#410)

Before #410 every failure (/api/proxy rejected / non-OK / non-JSON, or tryvio-boot.js not loading) revealed the trigger with an inline opacity:1 (which beats [disabled]{opacity:.45}) + disabled, while the explaining hint is display:none inside --floating. So an auto-placed pill sat over the gallery at full opacity and dead — the Kylyan incident (2026-08-21): the owner tapped it on his iPhone, nothing happened, and no event ever reached the DB.

Now ONE function owns the failure path — stage 1’s markUnavailable(ctx, stage, err), exported as kit.fail so stage 2’s run() catch degrades identically:

LauncherOn failure
auto-placed (--floating, incl. collection cards)hidden (--hidden → display:none) — no button beats a dead one
manual app blockstays, disabled at the CSS 0.45 (no inline override), hint = “Tryvio is temporarily unavailable.”
  • One retry: loadConfig retries a rejected / non-OK / non-JSON config exactly once, ~2 s later, same URL with cache:"no-store" (the liquid’s eager __tvCfg fetch counts as attempt one). Success on the retry reveals normally; a second failure gives up. Bounded, so it cannot stampede the proxy.
  • Debug badge: with ?tryvio_debug=1 the badge (now built by stage 1, kit.badge) renders on the failure path too — Tryvio <version> · config FAILED: http 503 / stage2 FAILED: ….
  • Observability: kit.report posts a tryon_widget_error event {stage, message, version, ua} via navigator.sendBeacon (text/plain body → no preflight; fetch keepalive fallback) on: config fail, stage-2 load fail, modal load fail (tryvio-widget.js), modal open exception. Stages: config, boot, stage2, modal_load, modal_open. See Analytics & ROI.
  • The #273 scrim is dropped on every failure; #351’s re-mount leaves a --hidden root alone.

Proof: playwright-debug/proof-410-config-failed-hidden.js (16 checks, fails 2/16 on the pre-#410 build) + three e2e-suite.js scenarios; 05_tasks/proof/410/.

Launchers the theme re-renders (#354)

onReady() observes document.body for added nodes, because themes (and tryvio-collection.js) inject launchers after load. Two rules govern what it arms:

  • By presence, never by value. snippets/tryvio-launcher.liquid writes data-tryvio-root with no value, so getAttribute() returns "" — falsy. The observer therefore tests hasAttribute("data-tryvio-root"). Until #354 it tested truthiness, and a manually placed tryvio-product-button that a theme re-rendered (Section Rendering API) came back as the top-level added node and was never bootstrapped: visible, but with no config and no click handler — a dead button. The observer’s nested branch (querySelectorAll) does not cover that case, because a node is not its own descendant. Measured 2026-08-13: 4 of the 12 readable prod storefronts place the manual block; for 3 of them it is the only launcher on the page (scripts/agents/manual-block-usage.mjs).
  • Auto-placed copies belong to #351. armRoot() declines a node with data-tryvio-autoplace="true" while floating[] is non-empty: the live launcher was moved into the theme’s gallery and #351’s re-mount owns it, deleting the inert copy the theme re-rendered. Arming that copy would race the re-mount for the same shopper-visible button. Manual blocks are never auto-placed, so they always take the normal path.

Re-arming is why the impression event is de-duplicated per page load — see Analytics & ROI → Impression de-dup.


Cards (collection / home / product-blocks)

Card buttons are injected by tryvio-collection.js after two lightweight calls (page-config + batch-status — one each per page, not per card). Each injected root is picked up by tryvio-theme.js’s MutationObserver, but through the lazy gate armRoot(): for --card roots it defers bootstrapRoot() (and the per-card /api/proxy) until the first pointerenter/pointerdown — so the heavy proxy fires only for cards the shopper touches, not ~57 at once (B23). The card button is shown immediately and stays visible (armRoot() restores opacity:1 right after kicking bootstrapRoot(), so hover never hides it). Sold-out removal for cards is independent (DOM badge / IntersectionObserver + /products.js).

Where a card button is allowed to appear (#233 → #350)

Two gates, in this order, both resolved by lib/widget/placement-resolution.ts — the ONE definition the liquid, the injector, the storefront API and the merchant panel all mirror:

  1. The theme embed emits data-placement per surface (cards_collection, cards_home, cards_product_blocks, cards_other_pages). Show / Hide are decisive and end the question. The legacy show_collection_button master gates only the Automatic path — with it off, the bootstrap script is never rendered at all.
  2. Automatic (the schema default) hands the question to the server, and since the owner decision of 2026-08-13 silence there means shown on every surface: enabling the embed IS the merchant’s “show the button”. store_widget_config.page_types only still decides for a store that actually chose — hasChosenPageTypes(), i.e. a non-empty stored array. That grandfather rule is the whole reason the change was safe to ship: 18 of 24 live stores had no row and were dark while their Theme Editor said “on” (#350, reported by a paying merchant), while the stores that had picked a narrower list — two of the three paying merchants among them — keep exactly what they picked. We hold read_themes only, so a saved choice can never be migrated into a merchant’s theme; honouring it server-side is the only place it can survive.

/api/storefront/page-config therefore serves the resolved list, which is what makes an old cached widget (it gates on that list alone) obey the new default without waiting for the extension to propagate. getShopifyStatus reads the embed’s own values out of the theme’s settings_data.json — the same fetch that already answers appEmbedEnabled, no extra round trip — so Settings → Widget mirrors the storefront’s real state and names the surface that decided it.

Which node the button is mounted on — never one that moves (#456)

Both injectors pick a host by heuristic: findImageWrapper (cards) keeps the outermost ancestor whose class matches /image|media|photo|thumb/; findProductMediaHost (PDP) ranks known gallery selectors by distance to the cart form. Both used to be able to land inside a slideshow slide, and a slide moves:

  • Cards — Horizon’s slide is slideshow-slide.product-media-container.media-fit (it matches the class test) while its parent slideshow-slides — the element carrying overflow-x: scroll — has no class at all, so the climb stopped inside the slide. The moment the theme previewed the second image on hover, the button rode slide 0 off-screen: measured on smotan.bg at x=54 → -394, with elementFromPoint at its centre returning null. A store hit by this does not complain; it just stops getting clicks.
  • Product page — .product-media-container is in SELECTORS, so on a gallery-left/form-right layout slide 2 (40px from the form) outranks the gallery that contains it (540px away). The launcher was parked on the second image and invisible until the shopper scrolled to it (slideIdx=1 of 2).

Since #456 both files climb out of any media track first and mount on the first stationary ancestor. A media track is structural, never a theme class name: more than one media child, AND either a declared scroller (overflow-x in auto|scroll|overlay) or a clipping box laid out wider than itself (overflow-x: hidden with scrollWidth > clientWidth — a translated Slick/Flickity track). The >1 media child half is what keeps a plain clipping frame out, so Dawn’s two-image hover swap (two stacked images, no layout overflow) keeps byte-identical placement. The walk is bounded — by the card in tryvio-collection.js, by the product section in tryvio-theme.js — so a page wrapper with overflow-x: hidden can never be mistaken for a gallery. On cards the pill is then measured against the track’s box rather than the first slide’s <img>, because the track is the visible media frame and the image walks away.

The image_container_selector Advanced setting still wins over all of it, and is now looked up before the heuristic rather than inside it: a selector the merchant typed is used as typed, even one pointing into a slide.

Caveat worth knowing when you verify this on a live Horizon store: the theme’s own slideshow-component relocates a stray child out of a slide a few milliseconds later, so the steady-state DOM can look correct while the placement decision was wrong. Measure the insertion (a MutationObserver armed before the asset runs), not the DOM a second later — proof-456-live-horizon.js does exactly that.


Backward-compat & rollback

  • API unchanged — additive only; /api/proxy runs its independent reads in parallel (Promise.allSettled) but the response shape is identical.
  • Deploy order — widget-only change (liquid + tryvio-theme.js + tryvio-boot.js + CSS). No API dependency. tryvio-boot.js is a NEW asset: an older cached widget version never asks for it, and a storefront on the new version always gets both files from the same immutable version tag.
  • Rollback — re-add trigger.style.opacity="0" in bootstrapRoot() (one line) to restore the gated behaviour; the inline bootstrap then becomes a harmless no-op.

Tests

  • playwright-debug/proof-72-bootstrap-trim.js — the app-block size budget + the stage-1/stage-2 boundary: __tvCfg reuse, theme apply/reveal, click queued before stage 2 → replayed, liquid data-tryvio-pending-open → replayed, #198 auto-placement, stage-2 unreachable → graceful degrade (8/8).
  • playwright-debug/proof-B22-instant-button.js — delayed tryvio-theme.js; asserts instant-visible, eager config before click, early click → spinner + replay-open, sold-out hidden (5/5).
  • playwright-debug/proof-B23-lazy-card-proxy.js — no proxy burst; lazy per-card fetch (4/4).
  • playwright-debug/proof-456-slideshow-mount.js — the button never mounts inside a slideshow slide: hover hit-test on a native scroller and a translated track, the PDP’s non-active slide, lazy image, card re-render, #351 re-mount, the override’s precedence, and a byte-comparison against the committed pre-fix assets so no-scroller themes are provably untouched (12/12).
  • playwright-debug/proof-456-live-horizon.js — the same two assets A/B’d on the live smotan.bg (4/4).
  • playwright-debug/e2e-suite.js — full modal flow + the #72 budget guard, 42/42.