@heyplaybook/hydrogen
v0.3.0
Published
Playbook store-edit tests for Shopify Hydrogen storefronts: server-side assignment, consent-gated visitor cookie, in-view exposures and cart attribution.
Maintainers
Readme
@heyplaybook/hydrogen
Playbook store-edit tests for Shopify Hydrogen storefronts.
Tests are defined and read out in Playbook. The variants are written in your storefront's own code. This package decides which arm each visitor sees (on the server, so the first paint is already right), sets a consent-aware visitor cookie, records an exposure when the tested element scrolls into view, and tags the cart so orders join the test.
- React is the only peer dependency.
- Web-standard
Request,Headersandfetchon the server: runs on Oxygen, Node or any worker runtime. - Assignment is GrowthBook's model (hash v2, namespaces, bucket ranges), ported under MIT and tested against GrowthBook's published vectors. It matches Playbook's platform core exactly, so readouts re-derive the same arms.
Install
npm install @heyplaybook/hydrogen
# or: pnpm add @heyplaybook/hydrogenQuickstart: three touchpoints (0.3.0+)
The kit moves all of the store wiring into the package. A store adds three things, plus a
two-line proxy route. Set PUBLIC_PLAYBOOK_SHOP_ID first (see Environment).
1. server.ts: one context, finalized on the way out
import {createPlaybookContext} from '@heyplaybook/hydrogen';
const playbook = createPlaybookContext({request, env, waitUntil});
// …getLoadContext: () => ({…, playbook})
if (session.isPending) response.headers.set('Set-Cookie', await session.commit());
if (response.status === 404) {
return playbook.finalize(await storefrontRedirect({request, response, storefront}));
}
return playbook.finalize(response);finalize appends (never sets) the visitor cookies, after your session cookie, and also on
a 404 that storefrontRedirect turned into a redirect. A redirect never waits on a
still-running rules fetch. Prefer one call? withPlaybook(options, async (playbook) => …)
wraps the handler and finalizes whatever it returns.
2. Root: <PlaybookHead> and <PlaybookRoot>
// root loader
return data({...(await context.playbook.loaderData()) /* playbook, hasPlaybookParams */});
// <head>
<PlaybookHead
shopId={ENV.PUBLIC_PLAYBOOK_SHOP_ID}
hasPlaybookParams={hasPlaybookParams}
state={playbook}
/>
// around the app (inside your cart provider)
const {id, attributes, status} = useCart();
<PlaybookRoot
state={playbook}
location={useLocation()}
navigate={useNavigate()}
cart={{cartId: id, attributes, status}}
>
<App />
</PlaybookRoot>PlaybookHeadrenders the anti-flicker style (only with Playbook URL params), the reveal hook, the visitor-id seed (UUIDs only) and the legacyplaybook.jsloader, which runs Playbook landing experiences. Passnonceif your store sets a CSP.PlaybookRootrenders your children untouched. Everything it adds is invisible and in its own error boundary: consent, page engagement, the_pb_vidcart attribute, the preview redirect, theaa-sitewideA/A sentinel, and the QA panel.cartis read only. The kit writes_pb_vidon its own request to your cart endpoint (default/api/cart;serverMerges: trueif that endpoint merges attributes itself). It never changes your cart status, so it can't block an add to cart.
3. The add-to-cart path
import {trackAddToCart} from '@heyplaybook/hydrogen';
// after a successful add (no user errors)
trackAddToCart('pdp', {lines: formData /* or the lines array */, cart: data.cart});Sources: pdp, quick_add, cart_upsell, cross_sell, other. A shared cart hook that
can't know the source calls setNextAddSource(source) in the UI and passes undefined.
Proxy route
// app/routes/apps.playbook.$.tsx
import {createPlaybookProxyHandler} from '@heyplaybook/hydrogen';
export const {loader, action} = createPlaybookProxyHandler();Reads PLAYBOOK_PLATFORM_URL from the load context. A slow platform gets a 504 after 5
seconds instead of holding your worker.
Cart attribute at creation (recommended)
Writing _pb_vid after the fact can race other attribute writers (Intelligems writes when
the cart id first appears). Attach it when the cart is created, in your cart action:
import {withPlaybookCartAttribute} from '@heyplaybook/hydrogen';
case 'Create':
result = await cart.create(withPlaybookCartAttribute(input, context.playbook.cartAttribute()));
case 'LinesAdd': // the first add creates the cart
const attr = context.playbook.cartAttribute();
result = attr && !cart.getCartId()
? await cart.create(withPlaybookCartAttribute({lines}, attr))
: await cart.addLines(lines);cartAttribute() reads cookies and consent only (no rules fetch on the cart path) and is
null without analytics consent. For a cart that already exists, the client writes once,
only after the cart has been idle for 3 seconds, only for a visitor in a running test, at
most 3 times per cart, and it puts back any _ig* key the write lost.
Consent adapters
Without one, consent comes from Shopify's Customer Privacy API (client) and the
_tracking_consent cookie (server). If your consent banner does not feed that API, pass
an adapter to both createPlaybookContext({consent}) and <PlaybookRoot consent>, so
the server and client make the same decision:
import {truevaultConsent} from '@heyplaybook/hydrogen'; // TrueVault PolaristruevaultConsent reads polaris_consent_settings.analyticsPermitted. With no cookie yet it
is pending (closed: nothing is tracked until the shopper's banner decision is recorded);
createTrueVaultConsent({whenMissing: 'granted'}) changes that.
Global Privacy Control (Sec-GPC: 1 / navigator.globalPrivacyControl) follows the
banner by default. GPC is an opt-out of sale and sharing, and Playbook doesn't sell data, so
GPC alone doesn't switch analytics off; the banner's analyticsPermitted decides (Polaris
handles GPC the same way). A store that wants GPC to deny analytics too passes
createTrueVaultConsent({gpc: 'deny'}).
Pending never writes or expires the persistent _pb_visitor_id; only a denial expires it.
GA4: view_experiment on the dataLayer
Each exposure is also pushed to window.dataLayer (on by default), once per visitor per
test per session, under the same consent decision as the exposure, and never for a forced
or preview arm:
{event: 'view_experiment', experiment_id: 'aa-sitewide', experiment_name: 'aa-sitewide',
variant_id: 'b', variant_name: 'b', experiment_variant: 'b', pb_vid: '<visitor id>'}experiment_variantmatches the legacyplaybook.jsplaybook_experimentpush, so one GTM variable reads both.experiment_nameis the test key unless the test's config setsexperimentName.<PlaybookRoot dataLayer={false}>turns it off;dataLayer={{eventName: '…'}}renames it.- No
window.dataLayer→ nothing happens. The kit never creates one. - Getting it into GA4 is the store's setup: its GTM container (or Fueled / Elevar) needs a
trigger on
view_experimentand a GA4 event tag that forwards these fields. BigQuery needs the store's GA4 → BigQuery export turned on.
Kill switch and rollback
| Setting | Effect |
| --- | --- |
| PLAYBOOK_TESTS_DISABLED=1 | Store tests off: everyone sees control, no visitor cookies, no cart attribute, no exposures or events. playbook.js keeps running. |
| Unset PUBLIC_PLAYBOOK_SHOP_ID | Everything off, including playbook.js. |
Change the variable in the storefront's environment and redeploy (Oxygen reads env at deploy). No code revert is needed.
QA panel
?pb_debug=1 opens the panel only on a preview host (*.myshopify.dev, Shopify previews,
localhost) or on a request with a verified pb_preview link. On the production domain a
public ?pb_debug=1 shows nothing. Pass debug={{implementedTests: ['…']}} to label tests
the storefront has no code for.
Manual integration (the building blocks)
The kit is built from these exports; use them directly if the kit doesn't fit.
1. Environment
| Variable | Required | What |
| --- | --- | --- |
| PUBLIC_PLAYBOOK_SHOP_ID | Yes | Your Playbook shop ID |
| PLAYBOOK_PLATFORM_URL | No | Defaults to https://www.heyplaybook.com. Set it only for a non-production Playbook |
| PLAYBOOK_TESTS_DISABLED | No | 1 turns off store tests only (the kit's kill switch) |
The kit (createPlaybookContext) reads these from env itself. No secrets reach the client. Without a shop ID the package is inert: everyone sees
control and no cookies are set.
2. Server: resolve once per document request
In your root loader (or a request context created in server.ts):
import {createPlaybook} from '@heyplaybook/hydrogen';
const playbook = await createPlaybook({
shopId: env.PUBLIC_PLAYBOOK_SHOP_ID,
platformUrl: env.PLAYBOOK_PLATFORM_URL,
request,
waitUntil, // keeps the background ruleset refresh alive on Oxygen
consentRequired: true, // set when your consent banner loads after first paint
});
// Put the state in loader data …
return {playbook: playbook.state /* , … */};Then append every cookie to the document response. Use append, never set, so your
session and cart cookies survive:
for (const cookie of playbook.setCookieHeaders) {
response.headers.append('Set-Cookie', cookie);
}createPlaybook waits at most 300 ms for the test rules (1.5 s on a cold start), then
uses the last good copy, or control for everyone. Rules are cached for 60 seconds. If
the rules fetch fails or hangs, createPlaybook stops calling Playbook for 30 seconds
and renders straight away with the last good copy (or control), so an outage costs
at most one 1.5 s wait every 30 seconds per worker. To
start the rules fetch earlier, call prefetchRuleset({platformUrl, shopId, waitUntil})
at the top of your request handler.
3. Provider
import {PlaybookProvider} from '@heyplaybook/hydrogen';
<PlaybookProvider state={rootData.playbook} pathname={location.pathname}>
<App />
</PlaybookProvider>Pass the current pathname so path targeting stays correct on client-side navigation.
4. Variants and exposure
import {usePlaybookExposure, usePlaybookVariant} from '@heyplaybook/hydrogen';
function Price({price}: {price: string}) {
const variant = usePlaybookVariant('pdp-price-after-code');
const exposureRef = usePlaybookExposure('pdp-price-after-code', variant);
// Attach the ref to an element that renders in BOTH arms.
return (
<div ref={exposureRef}>
<p>{price}</p>
{variant.arm === 'b' && <PriceAfterCode />}
</div>
);
}- Control must render exactly today's UI.
- An exposure is sent once per visitor per test per session, only when the element is on screen, only for visitors actually in the test, and never for forced or preview views.
usePlaybookVariant(key, {eligible})lets you keep a test off pages it doesn't apply to (for example, the wrong product type). A forced arm respectseligibletoo.usePlaybookInteraction(key, variant)returns a function for test-specific events (track('code_copied')).
5. Proxy route: /apps/playbook/*
Exposures and events POST to /apps/playbook/api/storefront/tests/… on your own domain,
so they stay first-party. Add a route that forwards /apps/playbook/<path> to
{PLAYBOOK_PLATFORM_URL}/<path>:
- forward the method, body,
content-type,user-agent,refererandx-forwarded-for; - set
x-forwarded-hostto your host; - return 204 responses with no body.
In Hydrogen (React Router) this is a splat route such as app/routes/apps.playbook.$.tsx
with a loader and an action that both proxy. To post somewhere else, pass
exposureUrl and eventUrl to PlaybookProvider.
6. Orders
When a cart exists, write the visitor ID as a cart attribute through your store's own attribute-merge path, so other attributes survive:
import {cartHasPlaybookAttribute, playbookCartAttribute} from '@heyplaybook/hydrogen';
if (visitorId && !cartHasPlaybookAttribute(cart.attributes, visitorId)) {
await mergeCartAttributes([playbookCartAttribute(visitorId)]); // {key: '_pb_vid', value}
}Re-check after cart updates: other scripts can replace the whole attribute list. Playbook's
orders/paid webhook reads _pb_vid from the order's note attributes.
Consent and identity
Consent comes from Shopify's Customer Privacy API (analytics purpose). Any consent
banner that feeds it works with no extra code.
| | Consent granted, or no consent system | Consent denied or still pending |
| --- | --- | --- |
| Variant renders | Yes | Yes (rendering is functional, never gated) |
| Visitor cookie | _pb_visitor_id, 365 days, set by the server | _pb_session_vid, session only, HttpOnly |
| Exposures and events | Sent | Not sent |
| _pb_vid cart attribute | Written | Not written |
While consent is pending, an existing _pb_visitor_id from an earlier grant is left as
it is (not refreshed, not expired). Only a denial expires it.
- One visitor ID, shared with Playbook's storefront script.
- Server-set, so Safari keeps it. Safari caps cookies written by browser scripts at 7
days. The server re-sends
_pb_visitor_idonce per browser session (a_pb_vssession marker), so a cookie the browser promoted after a consent grant becomes server-set. - A grant mid-visit promotes the session ID to the persistent cookie, keeping the same arm.
- Consent banners that load after first paint: pass
consentRequired: true, so a first visit with no_tracking_consentcookie is treated as pending (closed) instead of absent (open).
Previews and QA
?pb_preview=<token>: a signed preview link from Playbook. The package verifies it with the platform, forces that arm, and keeps it in an HttpOnly session cookie (SameSite=None; Secure, because Playbook frames the store cross-site).?pb_preview=offclears it. The cookie is only honoured on a framed request (Sec-Fetch-Dest: iframe); a normal top-level visit clears it, so a reviewer never carries a preview out of Playbook.- A preview is isolated (0.2.0+): only the previewed test renders, every other test
serves its control (
reason: 'preview-isolated'), overrides for other tests are ignored, and the visit writes no visitor cookies and has no visitor id (visitorId: null), so it sends no exposures or events and leaves no assignment behind. createPlaybook({overrides}): force arms for QA from your own code, for example on preview hosts only. Forced arms ignore status and traffic split, still respect page targeting, and never send exposures.usePreviewParamRedirect(location)returns the URL that re-adds the preview param after an in-app navigation, for frames where third-party cookies are blocked.- Preview requests must not send
X-Frame-Optionsor aframe-ancestorspolicy that excludes the Playbook app.
Behaviour
- Only
runningtests send exposures. Acompletedtest with awinnerserves the winner to every eligible visitor with no exposure.pausedserves control. - A
pb_newsession cookie (no identifier) keeps a first-time visitornewfor their whole first session. - Path targeting is evaluated against the current route on the client, ignoring a leading
locale segment (
/en-ca/products/…).
License
MIT. src/growthbook.ts is ported from GrowthBook's JavaScript SDK (MIT, © GrowthBook,
Inc.); see LICENSE.
