npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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, Headers and fetch on 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/hydrogen

Quickstart: 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>
  • PlaybookHead renders the anti-flicker style (only with Playbook URL params), the reveal hook, the visitor-id seed (UUIDs only) and the legacy playbook.js loader, which runs Playbook landing experiences. Pass nonce if your store sets a CSP.
  • PlaybookRoot renders your children untouched. Everything it adds is invisible and in its own error boundary: consent, page engagement, the _pb_vid cart attribute, the preview redirect, the aa-sitewide A/A sentinel, and the QA panel.
  • cart is read only. The kit writes _pb_vid on its own request to your cart endpoint (default /api/cart; serverMerges: true if 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 Polaris

truevaultConsent 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_variant matches the legacy playbook.js playbook_experiment push, so one GTM variable reads both. experiment_name is the test key unless the test's config sets experimentName.
  • <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_experiment and 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 respects eligible too.
  • 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, referer and x-forwarded-for;
  • set x-forwarded-host to 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_id once per browser session (a _pb_vs session 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_consent cookie 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=off clears 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-Options or a frame-ancestors policy that excludes the Playbook app.

Behaviour

  • Only running tests send exposures. A completed test with a winner serves the winner to every eligible visitor with no exposure. paused serves control.
  • A pb_new session cookie (no identifier) keeps a first-time visitor new for 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.