Add pos.resolution.action.render extension target - #4604
Conversation
|
This PR targets a stable release branch ( When you open the forward-port PR, include a line like this in its body so the Accepted formats (comma-separated for multiple):
If a forward-port isn't needed (e.g., the change is stable-only), you can remove the |
Revision summaryThis PR has been revised to drop the
Final API intersection: ActionTargetApi<'pos.resolution.action.render'> & CartApi & ReadonlyNavigationApiPR body rewritten to match. The two trailing design-question sections are preserved unchanged. |
71fdd4f to
72cb214
Compare
|
Revised: Also retargeted the base from |
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.
72cb214 to
7bc12e9
Compare
|
/snapit |
|
🫰✨ Thanks @henryStelle! Your snapshots have been published to npm. Test the snapshots by updating your "@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.
What this target is
pos.resolution.action.renderis a new POS extension target that renders a resolution side panel beside the cart when a merchant app'sbeforeCheckoutintercept 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
ResolutionApithat hands the extension the violation details. The app regenerates them by re-running its ownbeforeCheckoutvalidation 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.urlwith/{handle}, wherehandleis the one the app supplied on the blockingvalidationAdd. That is the only thing POS tells the extension: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.
currentEntryand thecurrententrychangeevent listener work normally, butnavigate(),back(),push(), andpop()throw — the host rejects all navigation writes.API intersection
ActionTargetApi<T>is{extensionPoint: T} & StandardApi<T> & ScannerApi— the same base every other*.action.rendertarget 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.
beforeCheckoutpaymentType(future, not implemented)For the current
beforeCheckoutevent, the extension gets: the fullStandardApi(includingScannerApi), a write-capableCartApi, and read-only navigation (currentEntryonly —navigate/backare rejected host-side). A futurepaymentTypeevent 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-domminimum_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
ResolutionApi/ResolutionApiContentthat handed the extension the violation's handle, severity, and message. This was removed: the app already owns the validation logic (it wrote thebeforeCheckoutinterceptor), 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.cart.currentto render success UI — a snapshot cannot reflect the item removal or discount application the merchant just performed.StandardApi→ActionTargetApi. The target initially usedStandardApi<'pos.resolution.action.render'>. It now usesActionTargetApi<...>, which addsScannerApiand matches the convention of every other*.action.rendertarget.Read-only navigation is enforced host-side
The
ReadonlyNavigationApitype exposescurrentEntryand navigation event listeners but omitsnavigate()andback(). Per the contract,currentEntryremains a writableSignaltype — the write path is rejected host-side in Shopify/extensibility, not at the type level. Per-target global narrowing of thenavigationglobal (following theBackgroundShopifyGlobalprecedent) is deliberately deferred for this prototype; aTODO(prototype)comment inglobals.tsexplains the rationale.New exported types
ReadonlyNavigationApi— read-only navigation type (currentEntry+ event listeners, nonavigate/back)Files changed
packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts— target registration + JSDocpackages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts— newReadonlyNavigationApipackages/ui-extensions/src/surfaces/point-of-sale/api.ts— exportReadonlyNavigationApipackages/ui-extensions/src/surfaces/point-of-sale/globals.ts—TODO(prototype)commentpackages/ui-extensions-tester/src/point-of-sale/factories.ts— mock factory + exhaustive map entry.changeset/pos-resolution-target.md— minor changesetDeliberately deferred
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).navigation— deferred (host-side enforcement for now).minimum_api_versionrequirement is noted above but not enforced in this repo; it lives inpos_ui.rb.Sibling PRs
Sibling PRs to follow (team_lead will cross-link):
Shopify/extensibility— host-side enforcement of read-only navigationshop/world— POS-mobile registry, component mapping, navigation seeding (/{handle}), Core allowlist,minimum_api_versiongateRevision:
ReadonlyNavigationis now aPick, and nested undernavigationTwo problems with the first pass, both fixed:
1. It redeclared every member of
Navigation.ReadonlyNavigationApihand-copiedcurrentEntry,addEventListener, andremoveEventListener, including their JSDoc. That isduplication that silently rots: a member added to
Navigationwould not appear here, and worse,a member whose signature changed on
Navigationwould quietly disagree with the copy. It is now:This tracks
Navigationautomatically, and the default for anything newly added toNavigationisto be excluded — the safe direction for a read-only view.
2. It flat-merged navigation members into the target api. The old interface put
currentEntrydirectly on the api object, so the type advertised
api.currentEntry. That is not how navigation isexposed: the host exposes it under a
navigationkey (and as thenavigationglobal). The type nowfollows the existing
CartApi/ReadonlyCartApiconvention:The mock factory in
ui-extensions-testerwas updated to the nested shape to match.Drive-by, called out for transparency:
navigation-api.tscontained the identicalexport interface Window { close(): void }block twice (pre-existing on the base branch, notintroduced 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 everyinterceptable resolution event (
beforeCheckouttoday;paymentTypeand 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:
RenderExtensionTargetsmaps one target key to exactly one api type, andbuildTargetDts.tsemits one.d.tsmodule per target key. So a single target cannot today tell anauthor writing for
paymentTypethat the cart is read-only while telling abeforeCheckoutauthor itis read+write — one of them would get types that lie.
This prototype only implements
beforeCheckout, so the question is not exercised here. It isrecorded 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 theinterceptscapability already presentin
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 deployedapp has to change. The opposite order is not: consolidating per-event targets later means deprecating
them and making every deployed app edit its TOML
targetstring and redeploy. Runtime cost is identicaleither 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:
Shopify/ui-extensionspos.resolution.action.rendertarget registration,ReadonlyNavigationApi, developer-facing JSDocShopify/extensibilityshop/worldThe
shop/worldPR cannot fully compile or run until the two package PRs land innode_modules; ambient.d.tsaugmentation is the interim workaround.