agentleFS
preetamnathpreetamnath/agent-skills/skills/post-purchase-ui-extension/SKILL.md

post-purchase-ui-extension

Skill4 starsChanged 7 months agoMITView on GitHub

SDK reference for `@shopify/post-purchase-ui-extensions-react` — Shopify's post-purchase upsell surface (the screen between payment and the thank-you page). Distinct from modern `<s-*>` checkout-extensions, which have no post-purchase target. TRIGGER when: code imports `@shopify/post-purchase-ui-extensions-react`; user mentions post-purchase offer, post-purchase page, post-purchase upsell, post-purchase-ui-extensions, ShouldRender, applyChangeset, or `Checkout::PostPurchase::*`; user edits files under `extensions/*post-purchase*/`.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Before you write a component prop, find it in the bundled .d.ts at …

    line 13
  2. Distinctive:

    If a component, prop, lifecycle field, or error code is missing from the Component Catalog or Lifecycle Contract below, WebFetch the …

    line 30
  3. Very specific:

    Sandbox: no DOM, no CSS, no window, no external scripts: All visual customization happens through component props.

    line 40
  4. Very specific:

    Use aspectRatio + fit="cover" to prevent layout shift and align cards in a Tiles grid when source images have varying intrinsic ratios.

    line 90
  5. Very specific:

    All importable from @shopify/post-purchase-ui-extensions-react.

    line 179

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Post-Purchase UI Extension

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

⚠️ Check every prop against the types

Before you write a component prop, find it in the bundled .d.ts at node_modules/@shopify/post-purchase-ui-extensions-react/build/ts/index.d.ts. Memory invents plausible fake props (spacing="base", Button tone).

NEVER run the shopify skill's search_docs.mjs or validate.mjs for this SDK. Its docs don't cover @shopify/post-purchase-ui-extensions-react. Its polaris-checkout-extensions validator accepts only Polaris web components, so it rejects every post-purchase component (BlockStack, Button, …).

⚠️ Skip if a different surface

  • Admin App Home <s-*> markup → use the shopify skill, topic polaris-app-home.
  • Modern checkout extensions (@shopify/ui-extensions-react, <s-*> web components) → use the shopify skill, topic polaris-checkout-extensions.
  • Customer account extensions → use the shopify skill, topic polaris-customer-account-extensions.

Doc lookup (WebFetch only)

  • Component props: https://shopify.dev/docs/api/checkout-extensions/post-purchase/components/<name> (lowercase — PascalCase URLs return 404, e.g. /components/blockstack works, /components/BlockStack does not)
  • Lifecycle, useExtensionInput, Changeset, InputData, ChangesetErrorCode: https://shopify.dev/docs/api/checkout-extensions/post-purchase/api
  • End-to-end tutorials: https://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-subscription
  • UX guidance (only when working on copy, layout, or offer framing — not for API/prop questions): https://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-subscriptions

If a component, prop, lifecycle field, or error code is missing from the Component Catalog or Lifecycle Contract below, WebFetch the canonical reference.

Rules

  • Two extension points, two phases. 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.
  • With Shop Pay, storage.initialData is EMPTY in Render. ShouldRender runs on shop.app and Render on the merchant's domain, and Storage does not cross domains (Shopify-documented under product-offers → Limitations, no workaround). Render must refetch when Storage is empty — Oak does it in index.jsx App via loadOffers(inputData, "render", …); Render's inputData carries the same token + initialPurchase. Bound the refetch with a timeout: Shopify's UX guidance says Render network calls should finish within 2s.
  • 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.
  • Always call 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.
  • Treat 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).
  • Sandbox: no DOM, no CSS, no 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 — but the scale differs per component (see Spacing scales).
  • .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.
  • Only import what the SDK re-exports from "react". The runtime bundles its own React — importing additional React entry points causes duplicate-React errors. useState, useEffect, etc. work because they pass through.

Common Patterns

These patterns are SDK-generic; the consuming repository's local instructions and code own repository-specific patterns.

Boilerplate entry point

The two-phase contract — every post-purchase extension starts with this skeleton.

import { extend, render, BlockStack } 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;   // empty under Shop Pay — refetch (see Rules)
  return <BlockStack spacing="loose">{/* … */}</BlockStack>;
}

Loading state on accept Button

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);
    try {
      const token = await signChangeset(variantId);
      await applyChangeset(token);
    } finally {
      done();
    }
  }}
>
  Add to order
</Button>

Image with locked aspect ratio

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 semantics via HeadingGroup

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>

Lifecycle Contract

Extension points

PointStringPurpose
ShouldRenderCheckout::PostPurchase::ShouldRenderData prefetch. Decide whether to render.
RenderCheckout::PostPurchase::RenderMount the React tree.

ShouldRender API

(api: PostPurchaseShouldRenderApi) => { render: boolean } | Promise<{ render: boolean }>

api exposes inputData: InputData, storage, plus version / locale / extensionPoint from the standard surface.

  • storage.update(data: any): Promise<void> — persist data for the Render phase. await it before returning.
  • Return { render: true } to mount, { render: false } to skip silently.

Render API

render("Checkout::PostPurchase::Render", (api: PostPurchaseRenderApi) => ReactElement)
FieldTypeUse
inputDataInputDataSame shape as ShouldRender.
storage.initialDataunknownData written by ShouldRender via storage.update. Read-only.
calculateChangeset(changeset: Readonly<Changeset> | string) => Promise<CalculateChangesetResult>Preview cost impact without applying. Pass either a raw Changeset object or the signed JWT string.
applyChangeset(changeset: string, options?: ApplyChangesetOptions) => Promise<ApplyChangesetResult>Apply the order edit and charge the buyer. Takes the backend-signed JWT string, never a raw object (see Rules).
done() => Promise<void>Navigate to thank-you page. Always call this, success or error.
version / locale / extensionPointfrom StandardApiAvailable alongside inputData.

InputData

FieldType
extensionPointstring
initialPurchasePurchase (referenceId, customerId?, destinationCountryCode?, totalPriceSet, lineItems[])
localestring
shopShop (id: number, domain, metafields)
tokenstring (JWT — pass to your backend for verification)
versionstring (current value: 'unstable')

LineItem: product, quantity, totalPriceSet, sellingPlanId?. Product: id, title, variant, metafields. Metafield.value is string | number; valueType is 'integer' | 'string' | 'json_string'.

Changeset shape

Changeset { changes: Changes }
Changes = (AddVariantChange | AddShippingLineChange | SetMetafieldChange | AddSubscriptionChange)[]

ApplyChangesetOptions

OptionTypeUse
buyerConsentToSubscriptions?booleanSet when changes include add_subscription; pair with BuyerConsent component. Optional in the type, but required by the server for subscription changes.

ChangesetErrorCode

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


Component Catalog

29 components, 28 doc URLs under /components/<name> (lowercase): FormLayoutGroup has no page of its own — fetch /components/formlayout.

All importable from @shopify/post-purchase-ui-extensions-react.

Spacing scales

Spacing literals differ per component — use exactly the values its .d.ts lists:

ScaleAllowed valuesUsed by
Stack scale'xtight' | 'tight' | 'loose' | 'xloose'BlockStack.spacing, InlineStack.spacing, Bookend.spacing
Stack scale + none'none' | 'xtight' | 'tight' | 'loose' | 'xloose'Tiles.spacing
Compact scale'none' | 'tight' | 'loose'TextContainer.spacing, CalloutBanner.spacing
View padding scale'extraTight' | 'tight' | 'base' | 'loose' | 'extraLoose'View.inlinePadding, View.blockPadding

xtight/xloose and extraTight/extraLoose are NOT interchangeable — each is rejected by the component that doesn't list it.

Layout & Structure

ComponentPurposeKey Props / Gotchas
BlockStackVertical stackspacing: stack scale (no none). alignment: 'leading' | 'center' | 'trailing'.
InlineStackHorizontal rowspacing: stack scale. alignment: 'leading' | 'center' | 'trailing' | 'baseline'.
BookendPin first/last child to intrinsic size, fill middleleading?: boolean, trailing?: boolean. spacing: stack scale. alignment: 'leading' | 'center' | 'trailing' | 'baseline'.
TilesEqual-size grid, wraps and stacks responsivelymaxPerLine?: number. breakAt?: number (px width below which tiles stack). spacing: stack scale + 'none'. alignment: 'leading' | 'center' | 'trailing' | 'baseline'. Direct children stretch — wrap a child in View to keep its intrinsic size.
LayoutMulti-section page scaffold with media-queried sizesmaxInlineSize?: number (≤1 = %, >1 = px). sizes?: Size[] where Size = 'auto' | 'fill' | number. media?: Media[] where Media = { viewportSize: 'small' | 'medium' | 'large'; maxInlineSize?: number; sizes?: Size[] }. inlineAlignment?: 'leading' | 'trailing'. blockAlignment?: 'center' | 'trailing'. No spacing prop exists on LayoutProps despite appearing in some doc code samples — using it is a TS error.
ViewGeneric container that does NOT stretchinlinePadding / blockPadding: View padding scale ('extraTight' | 'tight' | 'base' | 'loose' | 'extraLoose'). Note camelCase — NOT xtight/xloose. Use to opt out of Tiles/Layout stretching.
SeparatorVisual dividerdirection: 'horizontal' (default) / 'vertical'. width: 'thin' | 'medium' | 'thick' | 'xthick'.

Typography

ComponentPurposeKey Props / Gotchas
HeadingSection titlelevel?: 1 | 2 | 3 — visual override only; semantic level comes from HeadingGroup nesting. role?: 'presentation' strips semantics, keeps styling.
HeadingGroupIncrements heading level for nested childrenNo props. Wrap children that contain their own Heading to bump them down a level semantically.
TextInline styled textsize?: 'small' | 'medium' | 'large' | 'xlarge'. emphasized?: boolean, subdued?: boolean. id?: string. appearance?: 'critical' | 'warning' | 'success'. role: string 'address' or 'deletion' (use deletion for strikethrough on original prices), or an object: { type: 'abbreviation'; for?: string }, { type: 'directional-override'; direction: 'ltr' | 'rtl' } (direction is required), { type: 'datetime'; machineReadable?: string }. Inline only — wrap in TextBlock or a stack to break to a new line.
TextBlockBlock-level paragraphsize?: 'small' | 'medium' | 'large' | 'xlarge'. emphasized?: boolean, subdued?: boolean. id?: string. appearance?: 'critical' | 'warning' | 'success'. No role prop.
TextContainerVertical spacing wrapper for text elementsspacing?: 'none' | 'tight' | 'loose' (compact scale — xtight/xloose are NOT accepted here). alignment?: 'leading' | 'center' | 'trailing'.

Actions

ComponentPurposeKey Props / Gotchas
ButtonPrimary actiononPress?(): void (optional in the type — provide if not using submit or to). 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.
ButtonGroupInline-stacked buttons with auto-spacingNo props. Wraps two or more Buttons.
LinkNavigationto?: string and/or onPress?(): void — provide at least one. external?: boolean opens in new tab. id?: string (target for accessibility-label associations). Not a button — use Button for actions.

Forms

ComponentPurposeKey Props / Gotchas
FormForm wrapper with implicit-submit-on-EnteronSubmit(): void required. disabled?: boolean. implicitSubmit?: boolean | string (string = a11y label for screen-reader-only submit button). No <form> HTTP submission — handle in onSubmit.
FormLayoutVertical-stacked field layoutNo props. Children stack on the block axis.
FormLayoutGroupInline-grouped fields within a FormLayoutNo props. Fields appear side-by-side with equal spacing. Lives in the same .d.ts file as FormLayout.
TextFieldSingle-line inputlabel: string (required, doubles as placeholder when empty). value?: string, onChange?(value: string): void (fires on commit/blur). onInput?(value: string): void (fires every keystroke — drive controlled state from onInput, not onChange). type?: 'text' | 'email' | 'number' | 'telephone'. name?: string (form key). id?: string. required?: boolean (semantic only; does not auto-error). error?: string. multiline?: boolean. autocomplete?: Autocomplete | boolean. tooltip?: { label: string; content: string }. onFocus?(): void, onBlur?(): void.
SelectDropdownlabel: string (required). options: { value: string; label: string; disabled?: boolean }[]. value?: string, onChange?(value: string): void. placeholder?: string. id?: string, name?: string. required?: boolean. disabled?: boolean (on the Select itself, not just options). error?: string. autocomplete?: Autocomplete | boolean.
CheckboxBoolean togglechecked?: boolean (preferred) or value?: boolean — checked takes precedence when both are set. onChange?(value: boolean): void. disabled?: boolean, error?: string. id?: string, name?: string, accessibilityLabel?: string.
RadioSingle radio buttonname: string (required — same name groups options). checked?: boolean / value?: boolean. onChange?(value: boolean): void. disabled?: boolean. id?: string, accessibilityLabel?: string.
BuyerConsentSubscription consent checkboxpolicy: 'subscriptions'. checked: boolean (required), onChange(value: boolean): void (required — unlike other form components). error?: string. Required when applying an add_subscription change with applyChangeset(token, { buyerConsentToSubscriptions: true }).

Feedback & Status

ComponentPurposeKey Props / Gotchas
BannerStatus / system messagetitle?: string. status?: 'info' | 'success' | 'warning' | 'critical' (default 'info'). collapsible?: boolean, iconHidden?: boolean. For status reporting — not for promotional copy.
CalloutBannerPromotional offer headertitle?: string. background?: 'secondary' | 'transparent' (default 'secondary'). border?: 'none' | 'block' (default 'block'). alignment?: 'leading' | 'center' | 'trailing' (default 'center'). spacing?: 'none' | 'tight' | 'loose' (compact scale — xtight/xloose are NOT accepted; default 'tight'). For limited-time-offer framing — distinct from Banner.
SpinnerLoading indicatorsize?: 'small' | 'large'. color?: 'inherit'. Children = a11y fallback for reduced-motion users.

Media

ComponentPurposeKey Props / Gotchas
ImageResponsive imagesource: string (required). description?: string (alt; default ''). sources?: { source: string; viewportSize?: 'small' | 'medium' | 'large'; resolution?: 1 | 1.3 | 1.5 | 2 | 2.6 | 3 | 3.5 | 4 }[] for responsive variants — resolution is a constrained numeric literal union, not any number. aspectRatio?: number — sets height from width to prevent layout shift. fit?: 'cover' | 'contain' (pair with aspectRatio to avoid stretch). loading?: 'eager' | 'lazy'. bordered?: boolean, decorative?: boolean.

Accessibility

ComponentPurposeKey Props / Gotchas
HiddenForAccessibilityHide children from a11y tree but show visuallyNo props. Use for purely decorative or duplicated content.
VisuallyHiddenHide visually but keep available to screen readersNo props. Use for screen-reader-only labels.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.