Affiliate Hydration Cheatsheet
A single storefront page is cached once and served to every visitor — attributed or anonymous. Affiliate-specific values (name, avatar, link URLs) are filled in on the client by the FairShare SDK. This page tells theme authors which variable to use in which context and how to avoid the traps.
Note on code samples. In Liquid code samples below,
{`%`should be read as{%— drop the surrounding backticks when copying to your theme. Same for`%`}→%}.
The mental model
Under hydration:
- The rendered HTML is affiliate-independent. It contains placeholders (sentinels, custom elements, or data attributes) where affiliate values would go.
- The FairShare SDK runs on every page and swaps those placeholders with the resolved affiliate's values.
- Anonymous visitors (URL has no rep prefix) get the same HTML with fallback values (
"home"for URL segments, empty for text).
If you author a theme assuming affiliate values are baked into the HTML at render time, your theme will break under hydration. This page explains what changes.
The three mechanisms
| # | Mechanism | Use for | Example |
|---|---|---|---|
| 1 | Custom element — <fluid-affiliate-*> | Text content that displays an affiliate field | <span>Hi, <fluid-affiliate-name>friend</fluid-affiliate-name></span> |
| 2 | Data attribute — data-fluid-affiliate-* | An HTML attribute value (src, href, alt) that should hold an affiliate field | <img data-fluid-affiliate-src="avatar"> |
| 3 | Inline sentinel — {{ username }} | A rep username embedded inside a string (URL path segment, JSON attribute) | <a href="/{{ username }}/shop"> |
Each mechanism solves a case the others can't:
- A custom element can't live inside an attribute.
- A whole-attribute data marker would replace the entire value.
- The inline sentinel is safe only in attributes dereferenced on user interaction (
href,action), not eagerly-fetched ones (src).
Variable reference
Text fields — affiliate name, email, initials, avatar URL, my_site_url
Do not use these in template text or attributes; the raw value only exists on mysite renders.
Use the corresponding custom element:
| Field | Custom element | Fallback text |
|---|---|---|
| Name | <fluid-affiliate-name> | put default inside the tag |
<fluid-affiliate-email> | put default inside the tag | |
| Initials | <fluid-affiliate-initials> | put default inside the tag |
| Avatar URL | <fluid-affiliate-avatar> | put default inside the tag |
| MyHub URL | <fluid-affiliate-my-site-url> | put default inside the tag |
<p>Shop with <fluid-affiliate-name>our team</fluid-affiliate-name></p> <span class="badge"><fluid-affiliate-initials>FA</fluid-affiliate-initials></span>
The element lives in the light DOM (no Shadow DOM) so surrounding theme styles apply normally.
Attribute values — same fields, when used inside src, href, alt
Use a data marker on the host element:
| Attribute | Marker | Fills |
|---|---|---|
| Image source | data-fluid-affiliate-src="<field>" | src |
| Link href | data-fluid-affiliate-href="<field>" | href |
| Alt text | data-fluid-affiliate-alt="<field>" | alt |
| Any text | data-fluid-affiliate="<field>" | textContent (when a custom tag isn't usable) |
<field> is one of name, email, avatar, initials, my_site_url.
<img data-fluid-affiliate-src="avatar" data-fluid-affiliate-alt="name" /> <a data-fluid-affiliate-href="my_site_url">View store</a>
my_site_url normally resolves to <page-origin>/my/<username>. To point at a different origin (e.g. a preview environment or a whitelabelled domain), add data-fluid-affiliate-base-url on the same element:
<!-- Default: uses the current page's origin --> <a data-fluid-affiliate-href="my_site_url">View store</a> <!-- Explicit origin override --> <a data-fluid-affiliate-href="my_site_url" data-fluid-affiliate-base-url="https://staging.example.com" >View store</a>
The SDK resolves the affiliate's my_site_url against the supplied base URL instead of window.location.origin. Only affects my_site_url; other fields ignore this attribute.
URL path segments — {{ username }} and its siblings
For a rep username embedded inside a URL string, use the inline sentinel:
<a href="/{{ username }}/shop">Shop</a> <form action="/{{ username }}/join">...</form>
{{ username }} compiles to __fluid_affiliate_username__ under hydration. The SDK swaps every occurrence with the resolved rep username, falling back to "home" for anonymous visitors.
Other identifiers:
| Variable | Emits under hydration | Use for |
|---|---|---|
{{ username }} | Sentinel | Path segments in link/form URLs (e.g., /<username>/shop) |
{{ share_guid }} | Sentinel | Sharing-link surfaces where a stable identifier is needed |
{{ affiliate_guid }} | Sentinel | Legacy alias for share_guid |
{{ sharing_id }} | Sentinel | Sharing link variants where external IDs override the username |
{{ external_id }} | Real value | Backend-only surfaces (webhooks, non-cached contexts) |
Company URL helpers — company.shop_page_url, company.join_page_url
These already emit URLs with the sentinel baked in when hydration is on:
<a href="{{ company.shop_page_url }}">Shop</a> <!-- Renders to: <a href="/__fluid_affiliate_username__/shop">Shop</a> under hydration -->
No theme edit is needed. The SDK swaps the sentinel client-side.
Fields that stay real (no hydration)
| Variable | Value under hydration |
|---|---|
{{ affiliate.web_rep_store_enabled }} | Real (company-level setting, not per-rep) |
{{ affiliate.logged_in_rep_for_store }} | Real |
{{ affiliate.sign_in_url }} / sign_out_url | Real placeholders ("#") — use client-side JS to populate |
{{ company.* }} (except URL helpers above) | Real (not affiliate-dependent) |
Conditional visibility — the pattern that replaces {% if affiliate.X %}
Under hydration, {{ affiliate.name }} at render time is the placeholder — never blank. So:
{`%` if affiliate.name != blank `%`} <!-- ALWAYS true under hydration - broken -->
<span>Hi, {{ affiliate.name }}</span>
{`%` endif `%`}
Replace with the SDK's visibility toggle:
<span data-fluid-affiliate-show-if="affiliate" style="display:none"> Hi, <fluid-affiliate-name></fluid-affiliate-name> </span>
Tokens
| Token | Visible when |
|---|---|
affiliate | Any affiliate resolved (i.e., not the anonymous/"home" fallback) |
name, email, avatar, initials, my_site_url | That specific field has a value |
<!-- Show only when the resolved rep has an avatar --> <img data-fluid-affiliate-src="avatar" data-fluid-affiliate-show-if="avatar" style="display:none" /> <!-- Show only for anonymous visitors --> <div data-fluid-affiliate-hide-if="affiliate"> <a href="/join">Become a rep</a> </div>
Why the inline style="display:none"
The SDK toggles the element after the page loads. Without pre-hiding, an anonymous visitor sees the affiliate content flash before it disappears. The SDK removes display:none when the condition holds.
hide-if wins over show-if when both target the same element.
Common patterns — do this, not that
Affiliate name in text
<!-- NO — always evaluates truthy under hydration, block always renders -->
{`%` if affiliate.name != blank `%`}
<p>Hello, {{ affiliate.name }}</p>
{`%` endif `%`}
<!-- YES — SDK hides for anonymous, swaps name for attributed -->
<p data-fluid-affiliate-show-if="affiliate" style="display:none">
Hello, <fluid-affiliate-name></fluid-affiliate-name>
</p>
Avatar image
<!-- NO — browser fetches the literal sentinel URL, gets a broken image --> <img src="{{ affiliate.avatar }}" alt="{{ affiliate.name }}"> <!-- YES — SDK sets src after resolving affiliate, no broken request --> <img data-fluid-affiliate-src="avatar" data-fluid-affiliate-alt="name" data-fluid-affiliate-show-if="avatar" style="display:none" />
The src version breaks even without show-if because the browser tries to fetch the sentinel URL immediately. Always use data-fluid-affiliate-src for eagerly-fetched attributes.
Link with rep in URL
<!-- BOTH work — pick whichever is more readable --> <a href="/{{ username }}/shop">Shop</a> <a href="{{ company.shop_page_url }}">Shop</a>
Personalized greeting with fallback
<!-- NO — the fallback path never runs under hydration -->
{`%` if affiliate.name `%`}
Hi {{ affiliate.name }}!
{`%` else `%`}
Hi there!
{`%` endif `%`}
<!-- YES — element renders "there" for anonymous, swaps name for attributed -->
Hi <fluid-affiliate-name>there</fluid-affiliate-name>!
Note: the anonymous fallback is what's inside the custom element.
Show one of two variants (logged in / anonymous)
<!-- YES — render both, SDK reveals the right one --> <div data-fluid-affiliate-show-if="affiliate" style="display:none"> <p>Hello, <fluid-affiliate-name></fluid-affiliate-name></p> <a class="logout-link" href="#">Logout</a> </div> <div data-fluid-affiliate-hide-if="affiliate" style="display:none"> <p>Sign in to shop with your rep</p> <a class="signin-link" href="#">Sign in</a> </div>
Note on
affiliate.sign_in_url/sign_out_url: these two fields render as the placeholder"#"under hydration (see the Fields that stay real table above), so don't put them in anhrefand expect a working link. Wire the click behaviour up via a class-scoped click handler that calls the FairShare SDK's auth API — the SDK holds the real URLs even when the Liquid variables don't. For static links (e.g., a plain/logoutpage), you can also just hard-code the path.
Real-world migration walkthroughs
The patterns above cover the mechanics. What follows shows three real theme sections shaped exactly the way you'll encounter them — with all the surrounding markup, JS, and Alpine state — and their full migration. Each walkthrough highlights only the affiliate-related edits; everything else (styling, i18n, layout classes) stays untouched.
Walkthrough 1 — Sponsor name in a navbar row
The simplest shape: a conditional wrapper, a translated string, and the affiliate's name.
Before:
{`%` if affiliate and affiliate.name != blank `%`}
<div class="navbar-affiliate-row">
<span class="navbar-affiliate-name">
{{ 'navbar.you_are_joining' | t }} <strong>{{ affiliate.name }}</strong>
</span>
</div>
{`%` endif `%`}
After:
<div class="navbar-affiliate-row" data-fluid-affiliate-show-if="affiliate" style="display:none"> <span class="navbar-affiliate-name"> {{ 'navbar.you_are_joining' | t }} <strong><fluid-affiliate-name></fluid-affiliate-name></strong> </span> </div>
Two changes only:
{% if ... %} ... {% endif %}wrapper →data-fluid-affiliate-show-if="affiliate"+ inlinestyle="display:none"on the existing<div>.{{ affiliate.name }}→<fluid-affiliate-name></fluid-affiliate-name>.
Everything else — classes, i18n string, tag nesting — is byte-identical. Rendered output for an attributed visitor is the same; anonymous visitors see nothing.
Walkthrough 2 — FairShare banner with avatar + layout-syncing JS
Bigger shape: a full section with an avatar image, a section-height CSS variable driven by JavaScript, and a body class that other CSS reacts to.
Before:
{`%` if affiliate.name != blank `%`}
<link rel="stylesheet" href="{{ 'fairshare-banner/styles.css' | asset_url }}">
<section class="fairshare-banner" id="fairshare-banner-{{ section.id }}" role="status" aria-live="polite">
<div class="container">
<div class="fairshare-banner__content">
{`%` if affiliate.avatar != blank and section.settings.show_avatar `%`}
<img class="fairshare-banner__avatar" src="{{ affiliate.avatar }}" alt="{{ affiliate.name }}" />
{`%` endif `%`}
<span class="fairshare-banner__text">
{{ section.settings.prefix_text | default: 'You are shopping with' }}
<strong>{{ affiliate.name }}</strong>
</span>
</div>
</div>
</section>
<script>
(function () {
function setBannerHeight() {
var el = document.getElementById('fairshare-banner-{{ section.id }}');
if (!el) return;
document.documentElement.style.setProperty('--fairshare-banner-height', el.offsetHeight + 'px');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setBannerHeight);
} else {
setBannerHeight();
}
window.addEventListener('resize', setBannerHeight);
document.documentElement.classList.add('has-fairshare-banner');
})();
</script>
{`%` endif `%`}
After:
<link rel="stylesheet" href="{{ 'fairshare-banner/styles.css' | asset_url }}">
<section class="fairshare-banner"
id="fairshare-banner-{{ section.id }}"
role="status"
aria-live="polite"
data-fluid-affiliate-show-if="affiliate"
style="display:none">
<div class="container">
<div class="fairshare-banner__content">
{`%` if section.settings.show_avatar `%`}
<img class="fairshare-banner__avatar"
data-fluid-affiliate-src="avatar"
data-fluid-affiliate-alt="name"
data-fluid-affiliate-show-if="avatar"
style="display:none" />
{`%` endif `%`}
<span class="fairshare-banner__text">
{{ section.settings.prefix_text | default: 'You are shopping with' }}
<strong><fluid-affiliate-name></fluid-affiliate-name></strong>
</span>
</div>
</div>
</section>
<script>
(function () {
var el = document.getElementById('fairshare-banner-{{ section.id }}');
if (!el) return;
function syncBannerHeight() {
var visible = window.getComputedStyle(el).display !== 'none';
if (visible) {
document.documentElement.style.setProperty('--fairshare-banner-height', el.offsetHeight + 'px');
document.documentElement.classList.add('has-fairshare-banner');
} else {
document.documentElement.style.setProperty('--fairshare-banner-height', '0px');
document.documentElement.classList.remove('has-fairshare-banner');
}
}
new MutationObserver(syncBannerHeight).observe(el, {
attributes: true,
attributeFilter: ['style'],
});
window.addEventListener('resize', syncBannerHeight);
syncBannerHeight();
})();
</script>
Six changes:
- Outer
{% if affiliate.name != blank %}wrapper removed;data-fluid-affiliate-show-if="affiliate"+style="display:none"on the<section>. <link>moved out of the conditional — stylesheet loads for everyone (small overhead, cleaner than trying to conditionally load a CSS file).- Avatar conditional keeps the static
{% if section.settings.show_avatar %}(a theme-author toggle); drops theaffiliate.avatar != blankcheck. - Avatar
<img>:src="{{ affiliate.avatar }}"→data-fluid-affiliate-src="avatar",alt="{{ affiliate.name }}"→data-fluid-affiliate-alt="name", addeddata-fluid-affiliate-show-if="avatar"so the<img>hides when the rep has no avatar. {{ affiliate.name }}inside<strong>→<fluid-affiliate-name></fluid-affiliate-name>.- Script rewritten to use
MutationObserveron the section'sstyleattribute — the observer fires when the SDK togglesdisplay:none, at which point height + class are correct. The DOMContentLoaded path is gone: it would have measured 0 (element hidden) and added the class unconditionally.
Anonymous visitors now get --fairshare-banner-height: 0px and no .has-fairshare-banner class — the layout offset stays honest.
Walkthrough 3 — Sponsor banner with Alpine.js + server-injected affiliate props
Hardest shape: an Alpine component that takes affiliate values as x-data config, a modal with reactive bindings, form submission via window.FairShareSDK.captureLead(), and server-side Liquid ops (| downcase | capitalize) to Title-Case the name.
The Liquid changes look like the earlier walkthroughs — outer wrapper becomes data-fluid-affiliate-show-if="affiliate" on the <aside>, <img src="..."> becomes data-fluid-affiliate-src="avatar", {{ affiliate.name }} bindings become x-text="affiliateNamePretty". The two structural differences:
1. Delete the server-side name normalization.
{`%`- assign _name_parts = affiliate.name | downcase | split: ' ' -`%`}
{`%`- for _part in _name_parts -`%`}
...
{`%`- endfor -`%`}
{`%`- assign affiliate_first_name = affiliate_name_pretty | split: ' ' | first -`%`}
Under hydration, affiliate.name is a custom-element tag string — Liquid's downcase | split | capitalize on that produces garbage. The normalization moves to client-side JS.
2. Rewrite the Alpine component.
The original component receives affiliate values via x-data:
x-data="sponsorBanner({ affiliateName: {{ affiliate_name_pretty | json | escape }}, affiliateAvatar: {{ affiliate.avatar | default: '' | json | escape }} })"
Change to x-data="sponsorBanner()" (no config) and have the component read affiliate values from a hidden <fluid-affiliate-name> / <fluid-affiliate-avatar> probe it inserts itself. The SDK fills those elements; a MutationObserver keeps the Alpine state reactive:
init() { const probe = document.createElement('div'); probe.style.display = 'none'; probe.innerHTML = '<fluid-affiliate-name></fluid-affiliate-name>' + '<fluid-affiliate-avatar></fluid-affiliate-avatar>'; this.$root.appendChild(probe); const nameEl = probe.children[0]; const avatarEl = probe.children[1]; const sync = () => { const raw = (nameEl.textContent || '').trim(); this.affiliateName = raw; this.affiliateNamePretty = titleCase(raw); this.affiliateFirstName = this.affiliateNamePretty.split(' ')[0] || ''; this.affiliateAvatar = (avatarEl.textContent || '').trim(); }; sync(); new MutationObserver(sync).observe(probe, { childList: true, characterData: true, subtree: true, }); }, get initials() { const parts = (this.affiliateNamePretty || '').trim().split(/\s+/); return parts.slice(0, 2).map(p => p[0] || '').join('').toUpperCase() || '?'; },
Everything else about the component — modalOpen, form.*, state, canSubmit, submit() → captureLead(), openModal(), closeModal() — stays untouched. The modal's reactive bindings (:src="affiliateAvatar", x-text="initials", etc.) work as before; they just get populated from state the component now fills in itself.
Why this pattern is SDK-agnostic: the probe elements are what the SDK already knows how to fill. You don't have to hunt for a window.FluidFairshare.resolveAffiliate() API or race the SDK's ready event. However the SDK swaps the elements, the observer fires, and Alpine catches up.
Common shape across all three walkthroughs
Every migration has the same three moves:
- Move visibility to the SDK — replace
{% if affiliate.* %}withdata-fluid-affiliate-show-if="affiliate"+ inlinedisplay:none. - Move field rendering to the SDK — replace
{{ affiliate.<field> }}with a<fluid-affiliate-<field>>element or adata-fluid-affiliate-<attr>="<field>"marker. - Move dependent JS into a reactive callback — Alpine
init()with a MutationObserver probe, or plain-JS MutationObserver on the section'sstyleattribute for layout math.
If your section looks like one of these three, follow the walkthrough. If it looks like something else, use the mechanics above and cross-check against the diagnostic checklist inside the downloadable migration skill at the top of this page.
Advanced patterns
The three mechanisms cover the common cases. What follows are the shapes that come up in more complex themes.
Looping over affiliate-attributed content
Loops in Liquid run server-side. If the loop body renders an affiliate value that goes through hydration, each iteration produces the same sentinel — the SDK swaps them all at once when it runs.
{`%` for product in products `%`}
<a href="/{{ username }}/products/{{ product.slug }}">{{ product.title }}</a>
{`%` endfor `%`}
Server-rendered as N <a href="/__fluid_affiliate_username__/products/..."> tags. SDK swaps every occurrence of __fluid_affiliate_username__ in one document-wide pass. No special handling needed — the loop works because the sentinel is embedded in each item's URL.
If your loop body uses <fluid-affiliate-name> or data-fluid-affiliate-*, the SDK's DOM scan picks them all up too. Loop-agnostic.
SPA navigation (Turbo, Barba, custom)
When you swap the DOM without a full page reload, the SDK doesn't automatically know about the new sentinels. Two options:
Option 1 — call the SDK's re-scan API after each DOM swap:
document.addEventListener('turbo:load', function () { window.FairShareSDK?.rescanForSentinels?.(); });
Option 2 — rely on the SDK's MutationObserver, which watches for childList changes and re-scans automatically. If your SPA library dispatches DOM swaps as standard mutations, the SDK catches them without any explicit trigger.
Verify by navigating between two rep-attributed URLs in your SPA and confirming the affiliate name updates in the DOM. If it doesn't, add the explicit rescan call for that navigation event.
Rep switching mid-session
If a visitor lands on /rep-a/ and later navigates to /rep-b/, the SDK's affiliate state changes. Every SDK-managed value re-swaps:
<fluid-affiliate-name>text updatesdata-fluid-affiliate-src="avatar"re-fillssrcdata-fluid-affiliate-show-if="affiliate"re-evaluates (stays visible)
One case that requires care: raw __fluid_affiliate_username__ sentinels that have already been substituted can't re-swap for the new rep — the original sentinel string is gone. For text nodes that need to survive rep switches, prefer the <fluid-affiliate-name> custom element over the raw {{ username }} sentinel. The element re-renders on affiliate change; the sentinel doesn't.
For URL paths (/{{ username }}/shop), rep switching triggers a full page navigation anyway, so this rarely comes up in practice.
Preserving attribution across the cart / checkout flow
Cart and checkout are served directly from origin (they don't hit the cached storefront artifact). Attribution moves with each cart line as share_guid, set when the item is added.
Nothing in the theme's Liquid needs to change for attribution to survive checkout — it's handled by the SDK on cart mutations. But make sure every "Add to cart" <form> or <button> triggers the SDK's cart API rather than posting directly to origin, or the share_guid won't be attached.
Gotchas
JavaScript can't read hydrated values reliably
The SDK swaps values after the DOM is parsed. Any inline <script> that reads {{ affiliate.name }} or similar at initial evaluation gets the sentinel, not the value.
<!-- NO — repName is the sentinel string forever --> <script> const repName = '{{ affiliate.name }}'; document.title = `Shopping with ${repName}`; </script>
Instead: read the value from the DOM after the SDK resolves it, or use the FairShare SDK's JavaScript API if you need programmatic access.
JS that measures elements
If you compute a CSS variable or class based on an element's height, the initial measurement runs before the SDK reveals the element. Use MutationObserver on the element's style attribute:
<section id="banner" data-fluid-affiliate-show-if="affiliate" style="display:none"> ... </section> <script> (function () { var el = document.getElementById('banner'); if (!el) return; function syncHeight() { var visible = window.getComputedStyle(el).display !== 'none'; document.documentElement.style.setProperty( '--banner-height', visible ? el.offsetHeight + 'px' : '0px' ); } new MutationObserver(syncHeight).observe(el, { attributes: true, attributeFilter: ['style'], }); window.addEventListener('resize', syncHeight); syncHeight(); })(); </script>
Eagerly-fetched attributes must use data markers
The browser fetches these attributes immediately, before the SDK runs:
<img src>/<img srcset><video poster><link href>for stylesheets<script src>
Never put a sentinel or {{ affiliate.<field> }} directly in these — use the data-fluid-affiliate-src pattern.
Lazy attributes are safe:
<a href><form action>- Inline text nodes (no early fetch)
Mysite pages don't hydrate
Any template under /my/* (bio pages, favorites, personal links) renders with the real affiliate's values. Hydration is bypassed because mysite content is fundamentally per-rep.
If you're editing a mysite template, use {{ affiliate.name }} directly — the SDK isn't relied on for these pages.
<meta> and <link> in <head>
Sentinels in <meta og:url content> and <link rel="canonical" href> get swapped by the SDK, so social crawlers that read the raw HTML (Facebook, Twitter) may see "home" as the credit. For SEO-critical surfaces, prefer server-rendered values via the pre-cached artifact rather than hydration.
Testing
The SDK-side features work on every storefront — you can test theme changes on a non-hydration-enabled company before the flag is flipped. Two-phase test:
Phase 1 — SDK features (works on any company today):
- Deploy the updated theme
- Visit
https://<host>/<rep>/products/<slug>→ banner shows, avatar loads - Visit
https://<host>/home/products/<slug>→ banner hides, fallback text visible - Rep switch — visit a second rep's URL → banner shows the second rep
Phase 2 — full hydration (once the company is enabled):
- Purge the CDN cache for the pages you touched
- Visit an affiliate URL to fill the cache with sentinel-bearing HTML
- Verify the cache contains sentinels:
gsutil cat gs://themes-cdn-production/<host>/latest/pages/_default/<page>/<country>/<locale>.html \ | grep -c "__fluid_affiliate_username__" # Expect > 0
If sentinel count is 0, the cache was populated by an anonymous request first and contains the literal "home" — purge and re-fill from an affiliate URL.
Reading response headers
Every response includes diagnostic headers that reveal what happened. Use them as your first diagnostic:
curl -I -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36" \ "https://<host>/<rep>/products/<slug>" \ | grep -iE "x-hydration|x-credit|x-original-credit|x-cdn-cache|x-gcs-path"
| Header | Meaning |
|---|---|
x-hydration: on | Hydration is active for this request. Absent means hydration is not applied. |
x-credit: _default | Cache key is collapsed — every rep for this page shares one cached artifact. |
x-original-credit: <rep> | The rep from the request URL. Preserved for logs / analytics even when the cache key collapsed. |
x-cdn-cache: HIT / MISS | Cache state at request time. Second rep on a page should HIT. |
x-gcs-path: <host>/latest/pages/_default/<page>/... | Path to the cached artifact served. If it contains a rep name instead of _default, hydration isn't active for that path. |
Use -A "<browser UA>" in curl — many storefronts have bot-detection middleware that returns 500 for bare-curl User-Agents. A browser-shaped UA sidesteps that.
Troubleshooting
Common signals and what they mean.
The section flashes for anonymous visitors before disappearing
You're missing the inline style="display:none" on the data-fluid-affiliate-show-if element. The SDK sets display:none when its condition doesn't hold, but only AFTER it runs. Between page load and SDK ready, the element is visible.
Fix: add style="display:none" inline. The SDK removes it when the condition holds; leaves it in place when it doesn't. No flash either way.
Every request MISSes the cache
Two possible causes:
The cache was populated by an anonymous request first. Anonymous renders bake
"home"into URL paths (no sentinels). Diagnose with the sentinel grep in Testing. If count is 0, purge and re-fill from an affiliate URL.Hydration isn't active for this request. Check response headers as above. If
x-hydrationis absent, hydration isn't running — contact Fluid support to check status for your storefront.
Rep switches don't propagate
You navigate to /rep-b/ but still see rep-a's name. Two possibilities:
Full page navigation happened but the cached HTML has rep-a's name baked in literally, not as a sentinel. Grep the cache; if count is 0, the artifact was populated by a pre-hydration render. Purge and re-fill.
SPA navigation happened without triggering the SDK's rescan. See SPA navigation above.
The avatar image is broken
You're using <img src="{{ affiliate.avatar }}">. Under hydration this bakes the literal sentinel URL into src and the browser eagerly fetches it before the SDK can swap.
Fix: switch to <img data-fluid-affiliate-src="avatar"> — the SDK sets src after affiliate resolution.
__fluid_affiliate_username__ visible as literal text on the page
The FairShare SDK isn't running. Check in order:
- Is the SDK script tag present in
<head>or<body>of the rendered page? Most themes include it viatheme.liquid. - Is the SDK version recent enough to handle inline sentinels? The feature landed alongside the
<fluid-affiliate-*>element support. - Is a Content Security Policy blocking the script? Check the browser console for CSP errors.
The banner renders for anonymous visitors AND for reps
The data-fluid-affiliate-show-if attribute is missing, misspelled, or placed on the wrong element. Standard traps:
show-if="affiliate"(correct) vs.show-if="afiliate"(typo — won't error, just silently always-false)- Attribute on a child instead of the outer wrapper
- Attribute present but no inline
style="display:none"— element starts visible, is never hidden
Accessibility
Hydration adds a client-side rendering layer between initial page paint and the fully-populated view. A few things to keep in mind for screen readers and keyboard users.
alt on affiliate avatars
Never leave the <img> without an accessible name. Use data-fluid-affiliate-alt="name" so the SDK fills alt with the affiliate's name once resolved:
<img
data-fluid-affiliate-src="avatar"
data-fluid-affiliate-alt="name"
data-fluid-affiliate-show-if="avatar"
style="display:none"
/>
Do NOT set both static alt="..." AND data-fluid-affiliate-alt="name" — they race and one loses. Pick one: static for a generic label ("Your rep"), or the data-attribute for the actual name.
Screen readers see the pre-SDK state first
For anonymous visitors, an element with data-fluid-affiliate-show-if="affiliate" + style="display:none" stays hidden — invisible AND inaudible. Correct.
For attributed visitors, the element becomes visible AND audible when the SDK reveals it. Timing is typically fast (< 100ms after DOM ready) but if the content is a critical announcement, add aria-live="polite" to the wrapper so screen readers re-announce when the SDK swaps content in.
Keyboard operability
show-if / hide-if toggle display:none, which correctly removes elements from tab order. No extra tabindex management needed.
Content that must render for crawlers
Search engine crawlers (Googlebot, Bingbot) render as anonymous — they never see show-if="affiliate" content. That's usually correct. If you have SEO-critical content that must be indexed even without attribution, don't gate it behind show-if="affiliate".
Pre-flight checklist
Run through this before shipping a theme migration:
- Every
{% if affiliate %}/{% if affiliate.<field> %}replaced withdata-fluid-affiliate-show-ifor removed (Recipe R1) - Every
<img src="{{ affiliate.<field> }}">swapped todata-fluid-affiliate-src="<field>"(Recipe R2) - Text uses
<fluid-affiliate-<field>>custom elements — not raw server-side reads (Recipe R3) - Alpine.js
x-datano longer receives affiliate values as config — reads them from a DOM probe insideinit()(Recipes R4, R6) - Any layout-measuring JS uses
MutationObserveron the visibility toggle, notDOMContentLoadedmeasurements (Recipe R5) - Every
data-fluid-affiliate-show-if/hide-ifelement has inlinestyle="display:none"for FOUC prevention - Every
<img>withdata-fluid-affiliate-srcalso hasdata-fluid-affiliate-altfor accessibility - Tested with an attributed URL (
/<rep>/...) — banner shows, name populates, avatar loads - Tested with an anonymous URL (
/home/...or/) — banner hides, no broken image requests - Tested rep switch — visited two different
/<rep>/...URLs, name and avatar update - Sentinel grep on the cached artifact returns > 0
- No
<script>blocks read{{ affiliate.<field> }}into JS string literals — DOM probe pattern used instead - Response headers confirm
x-hydration: onandx-credit: _default
If every box checks, the theme is ready.
Summary — what to use when
| I want to display... | Use |
|---|---|
| Affiliate name / email / initials in text | <fluid-affiliate-name> etc. |
Affiliate avatar as an <img> | <img data-fluid-affiliate-src="avatar"> |
| A URL with rep username in the path | {{ username }} or company.shop_page_url |
| Content that shows only if a rep is attributed | data-fluid-affiliate-show-if="affiliate" + style="display:none" |
| Content that shows only for anonymous visitors | data-fluid-affiliate-hide-if="affiliate" |
| Content that shows only if the rep has a specific field | data-fluid-affiliate-show-if="<field>" |
Affiliate value inside a <script> or Alpine x-data | Use the DOM probe pattern — insert a hidden <fluid-affiliate-*> element, read its textContent in your component's init(), and re-read on MutationObserver events (see Walkthrough 3 above, plus Recipes R4/R6 in the downloadable migration skill) |
| A real (non-sentinel) affiliate value | Edit a mysite template (/my/*) — hydration bypasses those |
Further reading
- FairShare SDK — Affiliate Hydration — SDK-side reference for the custom elements, data attributes, and sentinels this page uses
- Template Variables reference — complete variable listing
- Developer Guide — theme structure basics
- Supported Paths — routing conventions