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
| File | Role | Load |
|---|---|---|
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.js | Injects 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 onwindow.__tvCfg[url]. The config request is in flight from parse time, parallel with page load. - Catch early clicks — capture-phase
pointerdown/clickon the trigger. If the root is not yet bootstrapped (data-tryvio-bootstrapped !== "true"):preventDefault, add the existing--loadingspinner (instant feedback), setdata-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:0gate applies only to autoplace, so the launcher button is never hidden by JS. - Eager reuse:
loadConfig(url)awaitswindow.__tvCfg[url](the inline prefetch) before doing its own fetch; the 30 ssessionStoragecache is the second tier. - Replay: after stage 2 wires the real handler, if
data-tryvio-pending-open === "1"orctx.clicked(a click stage 1’searlyClickcaught 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 withhideOnSoldOutOFF 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:
| Launcher | On failure |
|---|---|
auto-placed (--floating, incl. collection cards) | hidden (--hidden → display:none) — no button beats a dead one |
| manual app block | stays, disabled at the CSS 0.45 (no inline override), hint = “Tryvio is temporarily unavailable.” |
- One retry:
loadConfigretries a rejected / non-OK / non-JSON config exactly once, ~2 s later, same URL withcache:"no-store"(the liquid’s eager__tvCfgfetch 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=1the badge (now built by stage 1,kit.badge) renders on the failure path too —Tryvio <version> · config FAILED: http 503/stage2 FAILED: …. - Observability:
kit.reportposts atryon_widget_errorevent{stage, message, version, ua}vianavigator.sendBeacon(text/plain body → no preflight;fetch keepalivefallback) 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
--hiddenroot 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.liquidwritesdata-tryvio-rootwith no value, sogetAttribute()returns""— falsy. The observer therefore testshasAttribute("data-tryvio-root"). Until #354 it tested truthiness, and a manually placedtryvio-product-buttonthat 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 withdata-tryvio-autoplace="true"whilefloating[]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:
- The theme embed emits
data-placementper surface (cards_collection,cards_home,cards_product_blocks,cards_other_pages).Show/Hideare decisive and end the question. The legacyshow_collection_buttonmaster gates only theAutomaticpath — with it off, the bootstrap script is never rendered at all. 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_typesonly 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 holdread_themesonly, 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 parentslideshow-slides— the element carryingoverflow-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, withelementFromPointat its centre returningnull. A store hit by this does not complain; it just stops getting clicks. - Product page —
.product-media-containeris inSELECTORS, 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/proxyruns 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.jsis 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"inbootstrapRoot()(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:__tvCfgreuse, theme apply/reveal, click queued before stage 2 → replayed, liquiddata-tryvio-pending-open→ replayed, #198 auto-placement, stage-2 unreachable → graceful degrade (8/8).playwright-debug/proof-B22-instant-button.js— delayedtryvio-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.