@tabcommerceio/cross-sell-sdk
v0.1.5
Published
Storefront JavaScript SDK for Tab Cross Sell: recommendation resolve, valid-impression observation, click / add-to-cart tracking, and batched event delivery.
Maintainers
Readme
Cross Sell SDK
Storefront JavaScript SDK for Cross Sell recommendations. Owns the Cross Sell
network layer: recommendation resolve, impression observation, click / add-to-cart
tracking, idempotent event_id + occurred_at, and batched event flush.
Theme App Blocks and Agency UIs render their own DOM. The SDK mounts against
data-tab-cross-sell roots via host-provided render callbacks.
Product Bundles
Product Bundle Offers use the independent @tabcommerceio/buy-together-sdk.
It supplies resolve, exact Variant quote, and verified addAll with Bundle
tracking identities. This package continues to handle Cross Sell recommendations;
it does not mount Bundle roots or process Bundle events. Existing Cross Sell
integrations do not need to migrate because the app adds Product Bundles.
Install both packages when a custom storefront uses both workflows. Alias their
init imports and use separate clients, cards, observers, and tokens. The Bundle
package README provides the complete quote/add/error-handling example.
Install
npm install @tabcommerceio/cross-sell-sdk
# pnpm add @tabcommerceio/cross-sell-sdkimport { init } from '@tabcommerceio/cross-sell-sdk'ESM only, no runtime dependencies, ships its own TypeScript declarations. Browser-targeted (ES2022 + DOM APIs) — it is not meant to run on a server.
Inside this monorepo it stays a workspace package
("@tabcommerceio/cross-sell-sdk": "workspace:*"); releases run from the repo
root via npm run publish (see scripts/release/publish-sdk.mjs).
The committed manifest version is the release signal. A version change pushed
to main is validated and published by GitHub Actions through npm Trusted
Publishing in the shared publish-cross-sell-sdk.yml workflow, then tagged as
cross-sell-sdk@<version>. That workflow covers both public SDKs independently. Package source metadata
points to the private tabcommerce/tab-cross-sell repository. The local
command remains an authenticated recovery path.
Theme extension assets are built from src/theme/storefront.ts into
apps/theme-extension/assets/tab-cross-sell-storefront.js. That IIFE bundle is
internal to the official Theme App Extension and is not part of the npm
package.
Public API
const client = init({
proxyBase: '/apps/tab-cross-sell-dev', // App Proxy prefix/subpath
})
const result = await client.resolve({
placement: 'pdp',
source_products: [{ product_id: 'gid://shopify/Product/1' }],
limit: 3,
})
// Campaign/Rule identity lives on each candidate (ownership edge), not on the
// response root. Cart unions may mix owners across result.candidates /
// result.decisions[].
const candidate = result.candidates[0]!
client.track({
event_type: 'click',
tracking_token: candidate.tracking_token,
candidate_product_id: candidate.product_id,
request_id: result.request_id,
campaign_id: candidate.campaign_id,
rule_id: candidate.rule_id,
placement: 'pdp',
attrs: { variant_id: 'gid://shopify/ProductVariant/20' },
})
await client.mount({
root: document.querySelector('[data-tab-cross-sell="pdp"]')!,
request: { placement: 'pdp', limit: 3 },
render(result, root) {
// Host paints cards; return elements to observe for impressions.
// Prefer server-rendered card data-* identity over inventing owners.
root.innerHTML = '...'
return {
targets: [...root.querySelectorAll('[data-tracking-token]')].map((el) => ({
element: el,
tracking_token: el.getAttribute('data-tracking-token')!,
candidate_product_id: el.getAttribute('data-product-id')!,
candidate_variant_id: el.getAttribute('data-variant-id')!,
request_id: result.request_id,
campaign_id: el.getAttribute('data-campaign-id')!,
rule_id: el.getAttribute('data-rule-id')!,
placement: 'pdp',
})),
}
},
})
await client.flush()
client.destroy()Resolve response shape
| Field | Meaning |
|---|---|
| decisions[] | Per-Rule strategy, published versions, fingerprint, Holdout group, and audit attrs. PDP has at most one entry; cart_page / cart_drawer list every matched Rule. |
| candidates[] | Globally limited union for cart placements (or the single PDP winner pool). Each candidate carries its ownership-edge campaign_id / rule_id / tracking_token. |
| request-level ids | request_id, request tracking_token, placement, composite decision_version, fallback |
Do not read a singular top-level campaign_id / rule_id / strategy /
experiment_group from the resolve root — those fields were removed because
they cannot honestly describe a multi-Rule cart union. Observer, click, and
ATC paths validate per-card data-campaign-id / data-rule-id.
Invariants
| Behavior | Rule |
|---|---|
| Never block commerce | resolve timeout / 5xx / network error → null; no throw |
| Valid impression | ≥50% visible and ≥500ms while the page is visible; one impression per tracking_token |
| Event idempotency | retries resend the same event_id + occurred_at |
| Event delivery | batches never exceed the API limit of 100 and use fetch keepalive for navigation/page hide |
| Flush triggers | interval, queue threshold, visibilitychange, pagehide; failures remain queued for the next trigger |
| Variant attribution | impression/click/ATC events include the resolved Variant GID in attrs.variant_id |
| Owner-correct events | impression/click/ATC and cart line properties stamp the selected card's Campaign/Rule ownership edge |
| Add-to-cart truth | the official Quick Add writes _tab_tracking_token + _tab_request_id as line properties and emits add_to_cart only after Shopify returns success |
| Empty-slot stability | PDP starts presence + rendered concurrently and only explicit positive presence reveals a skeleton; Cart Page/Drawer call rendered only; rendered HTML is authoritative everywhere |
| Post-Quick-Add stability | preserve the current official markup for one app-originated refresh; later shopper cart edits resolve fresh |
Renderable decision fallback is owned by customer-api so fallback responses can
receive fresh request and candidate tracking tokens (reminted from each
candidate's own provenance). The SDK returns null on network failure instead
of replaying stale renderable decisions.
App Proxy paths
POST {proxyBase}/recommendations/resolvePOST {proxyBase}/events
proxyBase is /<app_proxy.prefix>/<app_proxy.subpath> from shopify.app.toml.
Agency usage
- Install the SDK from npm:
npm install @tabcommerceio/cross-sell-sdk. - Call
init({ proxyBase })with the shop’s App Proxy base (or your Agency gateway that preserves the OpenAPI shapes). - Render your own cards; call
track/mountas needed. - Include the resolved Variant GID on observe targets/events and stamp the two private attribution properties when your UI adds a recommended line to cart.
- Do not depend on official extension class names — only on the data you pass
into
render/track.
Cart Drawer
Prefer an explicit mount point:
<div data-tab-cross-sell="cart_drawer">...</div>Then wire the drawer controller so reopen/refresh normally re-resolves. The official integration has one narrow exception: after its own successful Quick Add it may restore the exact current markup through the originating theme redraw without a second rendered request. Unrelated body mutations do not consume the token; a bounded two-second grace covers delayed theme redraws, after which the normal fresh resolve path resumes.
The controller compares native cart-line identity/quantity markers inside the compatible Drawer shell. Shipping-progress, countdown, toast, and other presentation-only mutations do not trigger a recommendation request.
import { createCartDrawerController, init } from '@tabcommerceio/cross-sell-sdk'
const client = init({ proxyBase: '/apps/tab-cross-sell-dev' })
const drawer = createCartDrawerController({
documentRef: document,
async mount(root) {
return client.mount({
root,
request: { placement: 'cart_drawer', limit: 3 },
render(result, mountRoot) {
// Agency paints cards; return observe targets.
return { targets: [] }
},
})
},
})
drawer.start()When no mount point exists, the controller probes a compatible-theme selector
whitelist and waits silently on miss so a lazy compatible Drawer can appear
later (no DOM writes, recommendation requests, or console errors before a hit). See
packages/contracts/schemas/storefront/cross-sell-mount-points.md.
