Skip to content

Add pos.resolution.action.render extension target - #4604

Draft
henryStelle wants to merge 5 commits into
pos-intercept-apifrom
henry/pos-resolution-target-proto
Draft

Add pos.resolution.action.render extension target#4604
henryStelle wants to merge 5 commits into
pos-intercept-apifrom
henry/pos-resolution-target-proto

Conversation

@henryStelle

@henryStelle henryStelle commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What this target is

pos.resolution.action.render is a new POS extension target that renders a resolution side panel beside the cart when a merchant app's beforeCheckout intercept returns a blocking validation. POS launches this target automatically (not via a tile or menu item) and revalidates the cart when the side panel is closed.

No resolution API. There is no ResolutionApi that hands the extension the violation details. The app regenerates them by re-running its own beforeCheckout validation function against the live cart — one source of truth for validation logic.

The handle travels in the read-only navigation URL. POS seeds navigation.currentEntry.url with /{handle}, where handle is the one the app supplied on the blocking validationAdd. That is the only thing POS tells the extension:

const handle = navigation.currentEntry.url?.slice(1) ?? '';
const violations = myValidations(shopify.cart.current);
const violation = violations.find((v) => v.handle === handle);

Cart is live and read+write. The cart is the same live cart every other target sees. The app mutates it to resolve the problem (remove a restricted item, apply a required discount, scan an ID, etc.), observes the change reflected in cart.current, re-runs its validation function to confirm it passes, and renders its own success UI. POS also revalidates the live cart when the resolution flow closes.

Navigation is read-only. currentEntry and the currententrychange event listener work normally, but navigate(), back(), push(), and pop() throw — the host rejects all navigation writes.

API intersection

'pos.resolution.action.render': RenderExtension<
  ActionTargetApi<'pos.resolution.action.render'> &
    CartApi &
    ReadonlyNavigationApi,
  BasicComponents
>;

ActionTargetApi<T> is {extensionPoint: T} & StandardApi<T> & ScannerApi — the same base every other *.action.render target uses. Scanner is included because scanning a driver's licence or ID to clear an age-restriction block is a first-class use case for this target.

Static per-event API table

API exposure is static per intercept event name and documented in the target's JSDoc (which becomes the shopify.dev copy). There is no runtime capability introspection API.

Event Cart Navigation Standard Scanner
beforeCheckout read + write read-only yes yes
paymentType (future, not implemented) read-only read-only yes yes

For the current beforeCheckout event, the extension gets: the full StandardApi (including ScannerApi), a write-capable CartApi, and read-only navigation (currentEntry only — navigate/back are rejected host-side). A future paymentType event would receive read-only cart instead, documented here for forward compatibility.

API version requirement (resolved)

The handle channel depends on navigation.currentEntry, which the host only exposes on remote-dom api versions. This target therefore requires a remote-dom minimum_api_version. This is acceptable because the target is brand new — nothing is grandfathered. Version enforcement happens in the shop/world server registration (pos_ui.rb), not in this package.

Design revisions during review

  • Resolution API dropped. An earlier iteration of this PR introduced a ResolutionApi / ResolutionApiContent that handed the extension the violation's handle, severity, and message. This was removed: the app already owns the validation logic (it wrote the beforeCheckout interceptor), so it can regenerate violation detail by re-running that same function against the live cart. This keeps one source of truth and avoids a redundant host→extension data channel.
  • Snapshot cart rejected. A briefly-considered alternative gave the extension a frozen snapshot of the cart at intercept time. This was rejected because developers must see their own mutations reflected in cart.current to render success UI — a snapshot cannot reflect the item removal or discount application the merchant just performed.
  • StandardApiActionTargetApi. The target initially used StandardApi<'pos.resolution.action.render'>. It now uses ActionTargetApi<...>, which adds ScannerApi and matches the convention of every other *.action.render target.

Read-only navigation is enforced host-side

The ReadonlyNavigationApi type exposes currentEntry and navigation event listeners but omits navigate() and back(). Per the contract, currentEntry remains a writable Signal type — the write path is rejected host-side in Shopify/extensibility, not at the type level. Per-target global narrowing of the navigation global (following the BackgroundShopifyGlobal precedent) is deliberately deferred for this prototype; a TODO(prototype) comment in globals.ts explains the rationale.

New exported types

  • ReadonlyNavigationApi — read-only navigation type (currentEntry + event listeners, no navigate/back)

Files changed

  • packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts — target registration + JSDoc
  • packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts — new ReadonlyNavigationApi
  • packages/ui-extensions/src/surfaces/point-of-sale/api.ts — export ReadonlyNavigationApi
  • packages/ui-extensions/src/surfaces/point-of-sale/globals.tsTODO(prototype) comment
  • packages/ui-extensions-tester/src/point-of-sale/factories.ts — mock factory + exhaustive map entry
  • .changeset/pos-resolution-target.md — minor changeset

Deliberately deferred

  • Docs generation pipeline (yarn docs:point-of-sale) — not run; the JSDoc is in place and will be picked up in the next docs generation cycle.
  • reference/types/ExtensionTargetType.ts — not touched (docs category enum).
  • Examples / screenshots — not added.
  • Tests — not written or modified per prototype ground rules.
  • Per-target global narrowing for navigation — deferred (host-side enforcement for now).
  • Version gate — the remote-dom minimum_api_version requirement is noted above but not enforced in this repo; it lives in pos_ui.rb.

Sibling PRs

Sibling PRs to follow (team_lead will cross-link):

  • Shopify/extensibility — host-side enforcement of read-only navigation
  • shop/world — POS-mobile registry, component mapping, navigation seeding (/{handle}), Core allowlist, minimum_api_version gate

Revision: ReadonlyNavigation is now a Pick, and nested under navigation

Two problems with the first pass, both fixed:

1. It redeclared every member of Navigation. ReadonlyNavigationApi hand-copied
currentEntry, addEventListener, and removeEventListener, including their JSDoc. That is
duplication that silently rots: a member added to Navigation would not appear here, and worse,
a member whose signature changed on Navigation would quietly disagree with the copy. It is now:

export type ReadonlyNavigation = Pick<
  Navigation,
  'currentEntry' | 'addEventListener' | 'removeEventListener'
>;

This tracks Navigation automatically, and the default for anything newly added to Navigation is
to be excluded — the safe direction for a read-only view.

2. It flat-merged navigation members into the target api. The old interface put currentEntry
directly on the api object, so the type advertised api.currentEntry. That is not how navigation is
exposed: the host exposes it under a navigation key (and as the navigation global). The type now
follows the existing CartApi / ReadonlyCartApi convention:

export interface ReadonlyNavigationApi {
  navigation: ReadonlyNavigation;
}

The mock factory in ui-extensions-tester was updated to the nested shape to match.

Drive-by, called out for transparency: navigation-api.ts contained the identical
export interface Window { close(): void } block twice (pre-existing on the base branch, not
introduced here). Since this PR already edits that file, the duplicate was removed. TS merges
duplicate interfaces, so this is behaviour-neutral.

Open design question: one target vs one target per event

This PR registers a single target, pos.resolution.action.render, intended to serve every
interceptable resolution event (beforeCheckout today; paymentType and others later).

That choice is not settled. The original design call concluded the opposite — one target per
interceptable event — on the grounds that API access is granted per target. Research showed that
premise is not quite right: API composition is host-side and per-instance (it already varies on
apiVersion), so runtime scoping does not require per-event targets.

However, the stronger argument for per-event targets is developer type correctness, not runtime
enforcement: RenderExtensionTargets maps one target key to exactly one api type, and
buildTargetDts.ts emits one .d.ts module per target key. So a single target cannot today tell an
author writing for paymentType that the cart is read-only while telling a beforeCheckout author it
is read+write — one of them would get types that lie.

This prototype only implements beforeCheckout, so the question is not exercised here. It is
recorded because it determines whether this target string survives as-is or is split before GA.
Options under evaluation: per-event targets; per-event module subpaths emitted from one target key;
a generic ResolutionApi<Event>; or CLI codegen driven by the intercepts capability already present
in shopify.extension.toml. Reviewers should treat the target name as provisional.

Why ship one target anyway: the direction is cheaply reversible. Adding per-event targets later is
strictly additive — new events get new targets, this one keeps serving beforeCheckout, and no deployed
app has to change. The opposite order is not: consolidating per-event targets later means deprecating
them and making every deployed app edit its TOML target string and redeploy. Runtime cost is identical
either way, so the only asymmetry is migration, and it favours starting here.


Related PRs

This prototype spans three repos and is reviewed as a set:

Repo PR Contains
Shopify/ui-extensions #4604 pos.resolution.action.render target registration, ReadonlyNavigationApi, developer-facing JSDoc
Shopify/extensibility #1586 Host-side target/API policy, read-only navigation enforcement, multi-instance registration
shop/world #962681 Core server registration, POS-mobile target wiring, side-panel launch flow, revalidate-on-close

The shop/world PR cannot fully compile or run until the two package PRs land in node_modules; ambient .d.ts augmentation is the interim workaround.

@github-actions

Copy link
Copy Markdown
Contributor

This PR targets a stable release branch (2026-07). Once merged, the change typically also needs to be forward-ported to 2026-10-rc so it ships in the next release.

When you open the forward-port PR, include a line like this in its body so the needs-rc-port label gets removed automatically when that PR merges:

Forward-port of #4604

Accepted formats (comma-separated for multiple):

  • #4604
  • GH-4604
  • 4604
  • https://github.com/Shopify/ui-extensions/pull/4604

If a forward-port isn't needed (e.g., the change is stable-only), you can remove the needs-rc-port label manually.

@github-actions github-actions Bot added the needs-rc-port PR against a stable branch awaiting forward-port to the current RC label Jul 29, 2026
@henryStelle

Copy link
Copy Markdown
Contributor Author

Revision summary

This PR has been revised to drop the ResolutionApi and adopt the final design. Key changes since the original draft:

  • ResolutionApi / ResolutionApiContent deleted. The app regenerates violation detail by re-running its own beforeCheckout validation function against the live cart — one source of truth, no redundant host→extension data channel.
  • Handle travels in the read-only navigation URL. POS seeds navigation.currentEntry.url with /{handle}. That is the only thing POS tells the extension.
  • Cart is live and read+write (not a snapshot). The app mutates the cart, sees it reflected, re-runs validation, and renders its own success UI. A frozen snapshot was considered and rejected because developers must see their mutations reflected.
  • StandardApiActionTargetApi (= StandardApi + ScannerApi), matching every other *.action.render target. Scanning a driver's licence / ID to clear an age-restriction block is a first-class use case.
  • Remote-dom minimum_api_version requirement is a resolved decision (not an open question). navigation.currentEntry is only exposed on remote-dom api versions; enforcement lives in pos_ui.rb, not here. The target is brand new so nothing is grandfathered.

Final API intersection:

ActionTargetApi<'pos.resolution.action.render'> & CartApi & ReadonlyNavigationApi

PR body rewritten to match. The two trailing design-question sections are preserved unchanged.

@henryStelle
henryStelle force-pushed the henry/pos-resolution-target-proto branch from 71fdd4f to 72cb214 Compare July 29, 2026 19:32
@henryStelle
henryStelle changed the base branch from 2026-07 to 2026-10-rc July 29, 2026 19:32
@henryStelle

Copy link
Copy Markdown
Contributor Author

Revised: ReadonlyNavigation is now Pick<Navigation, 'currentEntry' | 'addEventListener' | 'removeEventListener'> instead of redeclaring each member, and ReadonlyNavigationApi is {navigation: ReadonlyNavigation} to match the CartApi/ReadonlyCartApi convention — the previous shape flat-merged currentEntry onto the api object, which is not how the host exposes navigation.

Also retargeted the base from 2026-07 to 2026-10-rc and rebased. loom type-check and loom build both pass.

Add the new POS resolution target that renders a side panel beside the
cart when a beforeCheckout intercept returns a blocking validation.

Changes:
- extension-targets.ts: register 'pos.resolution.action.render' with
  StandardApi + CartApi (read+write) + ReadonlyNavigationApi +
  ResolutionApi, BasicComponents. JSDoc documents the static per-event
  API table (beforeCheckout: cart read+write, nav read-only; future
  paymentType: cart read-only, nav read-only).
- api/resolution-api/: new ResolutionApi / ResolutionApiContent types
  per the cross-repo contract (event, handle, level, message, targetPath,
  metafields).
- api/navigation-api/: new ReadonlyNavigationApi exposing currentEntry
  and event listeners but not navigate/back. currentEntry remains a
  writable Signal type — write path rejected host-side, not type-side.
- api.ts: export the new types.
- globals.ts: TODO(prototype) comment explaining that per-target global
  narrowing for navigation is deferred (enforced host-side for now).
- ui-extensions-tester/factories.ts: add mock factory and exhaustive map
  entry for the new target.
- changeset: minor for @shopify/ui-extensions + @shopify/ui-extensions-tester

Assisted-By: devx/830dec82-6709-4fad-8f91-04af7aae54de
…ender

Per Henry: resolution should have scanner and all the action APIs.
ActionTargetApi<T> = {extensionPoint: T} & StandardApi<T> & ScannerApi,
matching every other *.action.render target. Scanner enables first-class
use cases like scanning a driver's licence/ID to clear age-restriction blocks.

Also document the remote-dom minimum_api_version requirement in the JSDoc
(currentEntry is only exposed on remote-dom api versions; enforcement is in
shop/world pos_ui.rb, not here).

Assisted-By: devx/c79cef1a-0b84-4b3e-8fac-b9ed2d04f9ec
…ion key

- ReadonlyNavigation = Pick<Navigation, 'currentEntry' | 'addEventListener' | 'removeEventListener'>
  instead of redeclaring each member, so it tracks Navigation automatically.
- ReadonlyNavigationApi is now {navigation: ReadonlyNavigation}, matching the
  CartApi / ReadonlyCartApi convention. Previously it flat-merged currentEntry
  into the target api, which did not match how the host exposes navigation.
- Removed an identical duplicate 'export interface Window' block in the same file.
@henryStelle
henryStelle force-pushed the henry/pos-resolution-target-proto branch from 72cb214 to 7bc12e9 Compare July 29, 2026 22:15
@henryStelle
henryStelle changed the base branch from 2026-10-rc to pos-intercept-api July 29, 2026 22:15
@henryStelle

Copy link
Copy Markdown
Contributor Author

/snapit

@shopify-github-actions-access

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @henryStelle! Your snapshots have been published to npm.

Test the snapshots by updating your package.json with the newly published versions:

"@shopify/ui-extensions": "0.0.0-snapshot-20260729221612",
"@shopify/ui-extensions-tester": "0.0.0-snapshot-20260729221612"

The 2026-07 branch lints stricter than 2026-10-rc:

- prettier wants the `CartApi &` intersection on one line in the tester
  factory.
- `no-warning-comments` rejects TODO markers. Kept the marker (it tracks a
  real gap in this draft) and disabled the rule on that line rather than
  weakening the comment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rc-port PR against a stable branch awaiting forward-port to the current RC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant