skills/post-purchase-extension/SKILL.md
Post-purchase UI extension SDK reference (29 React components, lifecycle, sandbox rules) for `@shopify/post-purchase-ui-extensions-react` — Shopify's only post-purchase SDK; distinct from the modern `polaris-checkout-extensions` web-component surface, which has no post-purchase target. Use when writing/editing JSX for `@shopify/post-purchase-ui-extensions-react`, building or modifying the screen rendered between payment and the thank-you page, or looking up a post-purchase component's props. TRIGGER when: code imports from `@shopify/post-purchase-ui-extensions-react`; user mentions post-purchase upsell, ShouldRender, applyChangeset, calculateChangeset, sign-changeset, or `Checkout::PostPurchase::*`; user asks to add or change a layout/template/component under `extensions/*post-purchase*/`.
npx skillsauth add preetamnath/agent-skills post-purchase-extensionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Component catalog, lifecycle contract, and sandbox rules for @shopify/post-purchase-ui-extensions-react (post-purchase upsell surface, npm 0.13.5, package in maintenance — no newer version exists; the modern <s-*> checkout-extensions SDK has no post-purchase target as of writing). Doc lookup is WebFetch-only — the Shopify Dev MCP doesn't index this SDK's API reference. No working MCP validator either; verify with tsc against the bundled .d.ts.
When generating or editing code for a Shopify post-purchase extension — any file under an extensions/*post-purchase*/src/ directory, or any JSX importing from @shopify/post-purchase-ui-extensions-react.
NOT for:
<s-*> markup — different SDK, different surface.@shopify/ui-extensions-react) — different SDK, different surface.extend("Checkout::PostPurchase::ShouldRender", …) is a data-prefetch hook; render("Checkout::PostPurchase::Render", App) is the React mount. Render runs only if ShouldRender returned { render: true }. See Lifecycle Contract.storage.update(data) in ShouldRender; storage.initialData in Render. Storage is the only hand-off between the two phases — they run in separate JS contexts.applyChangeset takes a signed JWT string, not a Changeset object. Sign the changeset on your backend with the app's API secret, return the token to the extension, then call await applyChangeset(token). Never sign client-side.done(), including on error paths. Documented behavior: done() "indicates that the extension has finished running" and "redirects customers to the Order status page." Build-guide code samples call it in both accept and decline branches. Operational rule (not stated in shopify.dev): if an accept handler throws or rejects before done() runs, the buyer is stuck on a blank screen — wrap accept handlers in try/finally to guarantee the call.ShouldRender as potentially called more than once per checkout. The shopify.dev pages do not document call frequency. Operational observation in production: it can fire on payment-page load and again after the buyer clicks Pay. Make the handler idempotent (backend dedupe of identical fetches keyed by referenceId).window, no external scripts. All visual customization happens through component props. There is no <style>, no className, no inline style={…}. Spacing comes from prop tokens (xtight / tight / loose / xloose on stack containers)..jsx / .js extension required in every import. The Shopify CLI bundler does not auto-resolve. import { X } from "./foo" fails; import { X } from "./foo.jsx" works."react". The runtime bundles its own React — importing additional React entry points causes duplicate-React errors. useState, useEffect, etc. work because they pass through.tsc, not the MCP. The polaris-checkout-extensions MCP validator is scoped to the modern @shopify/ui-extensions web-component SDK and rejects every post-purchase component (BlockStack, Button, …) as "not a Polaris web component." Run tsc --noEmit on the project source instead — TypeScript resolves types automatically via the bundled .d.ts at node_modules/@shopify/post-purchase-ui-extensions-react/build/ts/index.d.ts. Never call validate_component_codeblocks for post-purchase code — it will always fail.Find the component in the Component Catalog. If a component, prop, lifecycle field, or error code is missing, WebFetch the canonical reference:
https://shopify.dev/docs/api/checkout-extensions/post-purchase/components/<Name>useExtensionInput, Changeset, InputData, ChangesetErrorCode: https://shopify.dev/docs/api/checkout-extensions/post-purchase/apihttps://shopify.dev/docs/apps/build/checkout/product-offers/build-a-post-purchase-offer and https://shopify.dev/docs/apps/build/checkout/product-offers/create-a-post-purchase-subscriptionhttps://shopify.dev/docs/apps/build/checkout/product-offers/ux-for-post-purchase-product-offers and https://shopify.dev/docs/apps/build/checkout/product-offers/ux-for-post-purchase-subscriptionsThe Shopify Dev MCP does not index this SDK's API reference — do not invoke search_docs_chunks for it.
Write JSX following the rules above and the Common Patterns.
Type-check by running tsc --noEmit on the project source — TypeScript resolves types automatically via the bundled .d.ts at node_modules/@shopify/post-purchase-ui-extensions-react/build/ts/index.d.ts. The MCP validate_component_codeblocks tool does NOT cover this SDK — never invoke it for post-purchase code. If types fail twice on the same artifact, stop and surface the error to the user.
Generic SDK patterns. Repo-specific architecture (layouts/templates/components, normalize functions, config/token systems) lives in the consuming repo's architecture.md, not here.
The two-phase contract — every post-purchase extension starts with this skeleton.
import { extend, render } from "@shopify/post-purchase-ui-extensions-react";
extend("Checkout::PostPurchase::ShouldRender", async ({ inputData, storage }) => {
const data = await fetchOffer(inputData); // your backend
if (!data) return { render: false };
await storage.update(data);
return { render: true };
});
render("Checkout::PostPurchase::Render", App);
function App({ storage, applyChangeset, done }) {
const offer = storage.initialData;
return <BlockStack spacing="loose">{/* … */}</BlockStack>;
}
Button has a built-in loading prop — flip it on press to disable double-clicks during the sign-changeset round trip. No reset needed; done() navigates away.
const [loading, setLoading] = useState(false);
<Button
loading={loading}
loadingLabel="Processing"
onPress={async () => {
setLoading(true);
const token = await signChangeset(variantId);
await applyChangeset(token);
done();
}}
>
Add to order
</Button>
Use aspectRatio + fit="cover" to prevent layout shift and align cards in a Tiles grid when source images have varying intrinsic ratios.
<Image source={url} description={alt} aspectRatio={1} fit="cover" />
Heading levels are derived from HeadingGroup nesting depth — never set level manually unless you need to override visuals.
<HeadingGroup>
<Heading>Section title</Heading>
<HeadingGroup>
<Heading>Subsection title</Heading>
</HeadingGroup>
</HeadingGroup>
| Point | String | Purpose |
|---|---|---|
| ShouldRender | Checkout::PostPurchase::ShouldRender | Data prefetch. Decide whether to render. |
| Render | Checkout::PostPurchase::Render | Mount the React tree. |
({ inputData, storage }) => Promise<{ render: boolean }>
inputData: InputData — order context (see below).storage.update(data) — persist data for the Render phase.{ render: true } to mount, { render: false } to skip silently.({ inputData, storage, calculateChangeset, applyChangeset, done }) => JSX
| Field | Type | Use |
|---|---|---|
| inputData | InputData | Same shape as ShouldRender. |
| storage.initialData | unknown | Data written by ShouldRender via storage.update. Read-only. |
| calculateChangeset | (changeset \| signedToken) => Promise<CalculateChangesetResult> | Preview cost impact without applying. Optional — useful for "Total: $X" lines. |
| applyChangeset | (signedToken: string, options?: ApplyChangesetOptions) => Promise<ApplyChangesetResult> | Apply the order edit and charge the buyer. Token must be JWT-signed by your backend with the app secret. |
| done | () => Promise<void> | Navigate to thank-you page. Always call this, success or error. |
| Field | Type |
|---|---|
| extensionPoint | string |
| initialPurchase | Purchase (referenceId, customerId?, totalPriceSet, lineItems[]) |
| locale | string |
| shop | Shop (id, domain, metafields) |
| token | string (JWT — pass to your backend for verification) |
| version | string |
Changeset { changes: Change[] }
Change = AddVariantChange | AddShippingLineChange | SetMetafieldChange | AddSubscriptionChange
| Option | Type | Use |
|---|---|---|
| buyerConsentToSubscriptions | boolean | Required when changes include add_subscription; pair with BuyerConsent component. |
payment_required · insufficient_inventory · changeset_already_applied · unsupported_payment_method · invalid_request · server_error · buyer_consent_required · subscription_vaulting_error · subscription_contract_creation_error · subscription_no_shipping_address_error · subscription_limit_error · order_released_error
29 components total. All importable from @shopify/post-purchase-ui-extensions-react.
Spacing prop scale (used by BlockStack, InlineStack, Bookend, Tiles, TextContainer): none / xtight / tight / loose / xloose (not all components accept none — see notes). View uses a different scale: extraTight / tight / base / loose / extraLoose.
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| BlockStack | Vertical stack | spacing: xtight/tight/loose/xloose (no none). alignment: leading/center/trailing. |
| InlineStack | Horizontal row | Same spacing as BlockStack. alignment: leading/center/trailing/baseline. |
| Bookend | Pin first/last child to intrinsic size, fill middle | leading?: boolean, trailing?: boolean. spacing, alignment. |
| Tiles | Equal-size grid, wraps and stacks responsively | maxPerLine?: number. breakAt?: number (px width below which tiles stack). spacing: includes none. Direct children stretch — wrap a child in View to keep its intrinsic size. |
| Layout | Multi-section page scaffold with media-queried sizes | maxInlineSize?: number (≤1 = %, >1 = px). sizes?: Size[] (per section). media?: Media[] (responsive overrides). inlineAlignment/blockAlignment. |
| View | Generic container that does NOT stretch | inlinePadding / blockPadding: extraTight/tight/base/loose/extraLoose. Use to opt out of Tiles/Layout stretching. |
| Separator | Visual divider | direction: horizontal (default) / vertical. width: thin / medium / thick / xthick. |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| Heading | Section title | level?: 1 \| 2 \| 3 — visual override only; semantic level comes from HeadingGroup nesting. role="presentation" strips semantics, keeps styling. |
| HeadingGroup | Increments heading level for nested children | No props. Wrap children that contain their own Heading to bump them down a level semantically. |
| Text | Inline styled text | size: small/medium/large/xlarge. emphasized, subdued. role: address/deletion/abbreviation/directional-override/datetime (use deletion for strikethrough on original prices). Inline only — wrap in TextBlock or stack to break to a new line. |
| TextBlock | Block-level paragraph | Same size/emphasized/subdued as Text. appearance: critical/warning/success. |
| TextContainer | Vertical spacing wrapper for text elements | spacing: none/tight/loose. alignment: leading/center/trailing. |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| Button | Primary action | onPress(): void. submit?: boolean (form submit). to?: string (renders as Link). subdued?: boolean (secondary look), plain?: boolean (link-styled). loading?: boolean + loadingLabel?: string. disabled?: boolean. No variant/tone props — emphasis is via subdued/plain. |
| ButtonGroup | Inline-stacked buttons with auto-spacing | No props. Wraps two or more Buttons. |
| Link | Navigation | to?: string and/or onPress?(): void — must provide at least one. external?: boolean opens in new tab. Not a button — use Button for actions. |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| Form | Form wrapper with implicit-submit-on-Enter | onSubmit(): void required. disabled?: boolean. implicitSubmit?: boolean \| string (string = a11y label for screen-reader-only submit button). No <form> HTTP submission — handle in onSubmit. |
| FormLayout | Vertical-stacked field layout | No props. Children stack on the block axis. |
| FormLayoutGroup | Inline-grouped fields within a FormLayout | No props. Fields appear side-by-side with equal spacing. |
| TextField | Single-line input | label: string (required, doubles as placeholder when empty). value, onChange(value: string). type: text/email/number/telephone. name (form key). |
| Select | Dropdown | label: string (required). options: { value, label, disabled? }[]. value, onChange(value: string). placeholder. |
| Checkbox | Boolean toggle | checked (preferred) or value: boolean. onChange(checked: boolean). disabled?, error?: string. |
| Radio | Single radio button | name: string (required — same name groups options). checked/value. onChange. |
| BuyerConsent | Subscription consent checkbox | policy: "subscriptions". checked: boolean, onChange(value: boolean). error?: string. Required when applying an add_subscription change with applyChangeset({ buyerConsentToSubscriptions: true }). |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| Banner | Status / system message | title?: string. status: info (default) / success / warning / critical. collapsible?, iconHidden?. For status reporting — not for promotional copy. |
| CalloutBanner | Promotional offer header | title?: string. background: secondary (default) / transparent. border: block (default) / none. alignment: leading/center (default)/trailing. spacing: none/tight/loose. For limited-time-offer framing — distinct from Banner. |
| Spinner | Loading indicator | size: small / large. color: inherit. Children = a11y fallback for reduced-motion users. |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| Image | Responsive image | source: string (required). description?: string (alt). sources?: { source, viewportSize?, resolution? }[] for responsive variants. aspectRatio?: number — sets height from width to prevent layout shift. fit: cover / contain (pair with aspectRatio to avoid stretch). loading: eager / lazy. bordered?, decorative?. |
| Component | Purpose | Key Props / Gotchas |
|---|---|---|
| HiddenForAccessibility | Hide children from a11y tree but show visually | No props. Use for purely decorative or duplicated content. |
| VisuallyHidden | Hide visually but keep available to screen readers | No props. Use for screen-reader-only labels. |
documentation
Collapse a multi-clause instruction into one positive line of trigger + action. TRIGGER when: user says 'tighten this rule', 'make this leaner', 'make this simpler' in a skill, CLAUDE.md, agent prompt, or style guide.
documentation
File-level tightening pass on an instruction file (CLAUDE.md, skill, agent prompt, style guide) using `tighten-instruction` as the lens. TRIGGER when: user says 'tighten/simplify this file/skill/CLAUDE.md', 'cut this down'; user points at a verbose instruction file and wants it leaner.
testing
Anchored second-opinion on one concrete proposal: dispatch a subagent to rate the fix, generate ranked alternatives, and flag blind spots, then synthesize back. TRIGGER when: user says 'second opinion', 'rate my fix', 'weigh in on my approach', 'what alternatives am I missing', or wants their candidate edit/decision evaluated against alternatives. SKIP when: multiple decisions on a larger artifact — use `panel-review`.
development
Multi-reviewer panel on N focused questions about a near-final artifact (plan, design, code, prose). R0 (you) plus two parallel reviewer subagents, per-question table with disagreement preserved, walk decisions one at a time. TRIGGER when: user says 'panel review', 'multi-agent review'; user has a mostly-done artifact and focused micro-decisions to validate. SKIP when: only one proposal under review — use `second-opinion`.