From ba6810b972fdc1c8630bc2f0d0dd24614b58cc0b Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Wed, 29 Jul 2026 10:08:23 -0700 Subject: [PATCH 1/5] Add pos.resolution.action.render extension target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/pos-resolution-target.md | 6 +++ .../src/point-of-sale/factories.ts | 43 +++++++++++++++++++ .../src/surfaces/point-of-sale/api.ts | 11 +++++ .../api/navigation-api/navigation-api.ts | 36 ++++++++++++++++ .../api/resolution-api/resolution-api.ts | 41 ++++++++++++++++++ .../point-of-sale/extension-targets.ts | 37 ++++++++++++++++ .../src/surfaces/point-of-sale/globals.ts | 11 +++++ 7 files changed, 185 insertions(+) create mode 100644 .changeset/pos-resolution-target.md create mode 100644 packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts diff --git a/.changeset/pos-resolution-target.md b/.changeset/pos-resolution-target.md new file mode 100644 index 0000000000..00b1fc9158 --- /dev/null +++ b/.changeset/pos-resolution-target.md @@ -0,0 +1,6 @@ +--- +'@shopify/ui-extensions': minor +'@shopify/ui-extensions-tester': minor +--- + +Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes the standard API, a write-capable Cart API, read-only navigation (`currentEntry` only — `navigate`/`back` are rejected host-side), and a new `ResolutionApi` that exposes the validation handle, severity, and message. diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index 6c538ee5cc..a7e0d2b00f 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -26,6 +26,8 @@ import type { CashTrackingSessionCompleteData, CartUpdateEventData, Money, + ResolutionApi, + ReadonlyNavigationApi, } from '@shopify/ui-extensions/point-of-sale'; import {createReadonlySignalLike} from '../mocks/signals'; @@ -256,6 +258,29 @@ function createMockCashDrawerApi(): CashDrawerApi { return {cashDrawer: {open: async () => {}}}; } +function createMockResolutionApi(): ResolutionApi { + return { + resolution: { + event: 'beforeCheckout', + handle: 'mock-handle', + level: 'error', + message: 'This cart requires resolution before checkout.', + }, + }; +} + +function createMockReadonlyNavigationApi(): ReadonlyNavigationApi { + return { + currentEntry: { + key: 'mock-key', + url: '/mock-handle', + getState: () => null, + }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; +} + // --------------------------------------------------------------------------- // Group factory functions — each composes the correct API for a target group // --------------------------------------------------------------------------- @@ -449,6 +474,21 @@ function createActionTargetCashDrawerMock( }; } +// Group R: StandardApi + CartApi + ReadonlyNavigationApi + ResolutionApi +function createResolutionTargetMock( + target: T, +): StandardApi & + CartApi & + ReadonlyNavigationApi & + ResolutionApi { + return { + ...createMockStandardApi(target), + ...createMockCartApi(), + ...createMockReadonlyNavigationApi(), + ...createMockResolutionApi(), + }; +} + // Data target factories function createDataTargetMock( target: T, @@ -618,6 +658,9 @@ const posMockFactories: PosMockFactory = { // Group Q: ActionTargetApi + CashDrawerApi 'pos.register-details.action.render': createActionTargetCashDrawerMock, + // Group R: StandardApi + CartApi + ReadonlyNavigationApi + ResolutionApi + 'pos.resolution.action.render': createResolutionTargetMock, + // Data targets 'pos.app.ready.data': createDataTargetMock, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts index 59394033d5..721140e164 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -20,6 +20,17 @@ export type { ExtensionApiContent, } from './api/extension-api/extension-api'; export type {ActionTargetApi} from './api/action-target-api/action-target-api'; +export type { + ResolutionApi, + ResolutionApiContent, +} from './api/resolution-api/resolution-api'; +export type { + ReadonlyNavigationApi, + Navigation, + NavigationNavigateOptions, + NavigationHistoryEntry, + NavigationCurrentEntryChangeEvent, +} from './api/navigation-api/navigation-api'; export type {DataTargetApi} from './api/data-target-api/data-target-api'; export type { TransactionCompleteEvent, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts index 69787040b6..ca5641a020 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts @@ -39,6 +39,42 @@ export interface NavigationCurrentEntryChangeEvent { from: NavigationHistoryEntry; } +/** + * Read-only view of the `Navigation` object for extension targets that should + * not be able to navigate programmatically (e.g. `pos.resolution.action.render`). + * Exposes `currentEntry` and navigation event listeners but omits `navigate` + * and `back`. + * + * Note: `currentEntry` retains its writable `Signal` type intentionally — the + * write path is rejected host-side, not at the type level. + * + * @publicDocs + */ +export interface ReadonlyNavigationApi { + /** + * Returns a `NavigationHistoryEntry` object representing the location the user is currently navigated to. Use to access current URL, navigation state, or implement navigation-aware functionality based on the current location. + */ + currentEntry: NavigationHistoryEntry; + /** + * Registers an event listener for navigation events. The `currententrychange` event fires when the `currentEntry` property changes, such as when the user navigates to a different screen within the extension modal. Use to track navigation changes, update UI state based on the current location, or implement analytics for navigation patterns. + * @param type - The event type to listen for. Currently only `'currententrychange'` is supported. + * @param cb - The callback function invoked when the event fires. Receives a `NavigationCurrentEntryChangeEvent` containing the previous entry that was navigated away from. + */ + addEventListener( + type: 'currententrychange', + cb: (event: NavigationCurrentEntryChangeEvent) => void, + ): void; + /** + * Removes a previously registered event listener. The callback reference must match the one passed to `addEventListener`. Use to clean up event listeners when they are no longer needed, such as when a component unmounts or navigation tracking should be disabled. + * @param type - The event type to remove the listener for. Currently only `'currententrychange'` is supported. + * @param cb - The callback function to remove. Must be the same function reference that was passed to `addEventListener`. + */ + removeEventListener( + type: 'currententrychange', + cb: (event: NavigationCurrentEntryChangeEvent) => void, + ): void; +} + /** * The `Navigation` object provides navigation controls for extension modals. * @publicDocs diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts new file mode 100644 index 0000000000..dec42e5112 --- /dev/null +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts @@ -0,0 +1,41 @@ +/** + * Describes the validation that the resolution screen is resolving. + * @publicDocs + */ +export interface ResolutionApiContent { + /** + * The intercept event being resolved. + */ + event: 'beforeCheckout'; + /** + * The app-authored handle of the validation this screen is resolving. + * Raw, NOT namespaced. + */ + handle: string; + /** + * Severity of the validation being resolved. + */ + level: 'error' | 'warning' | 'info'; + /** + * The message POS would have shown natively. + */ + message: string; + /** + * Optional pointer to what the validation applies to, e.g. a line item. + */ + targetPath?: string; + /** + * App-supplied metafields carried through from the intercept result. + */ + metafields?: Record; +} + +/** + * Provides access to the resolution context for a `pos.resolution.action.render` + * extension target. The `resolution` property describes the validation that + * this screen is resolving. + * @publicDocs + */ +export interface ResolutionApi { + resolution: ResolutionApiContent; +} diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts index ac77935b49..a3de7cab60 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts @@ -25,6 +25,8 @@ import type { OrderApi, StorageApi, CashDrawerApi, + ResolutionApi, + ReadonlyNavigationApi, } from './api'; import type {ActionExtensionComponents} from './components/targets/ActionExtensionComponents'; import type {BlockExtensionComponents} from './components/targets/BlockExtensionComponents'; @@ -122,6 +124,41 @@ export interface RenderExtensionTargets { ActionTargetApi<'pos.home.modal.render'> & CartApi, BasicComponents >; + /** + * Renders a resolution side panel beside the POS cart when a merchant app's + * `beforeCheckout` intercept returns a blocking validation. POS launches this + * target automatically — it is not triggered by a tile or menu item — and the + * extension receives the validation's handle, severity, and message through + * the Resolution API. + * + * The extension uses the Cart API (read + write) to let the merchant resolve + * the validation (e.g. remove a restricted item, apply a required discount). + * Navigation is read-only: `currentEntry` is available so the extension can + * read its URL-seeded handle, but `navigate()` and `back()` are rejected by + * the host. + * + * **Static per-event API table** (API exposure is static per intercept event + * name, documented here — there is no runtime capability introspection): + * + * | Event | Cart | Navigation | Resolution | Standard | + * | --- | --- | --- | --- | --- | + * | `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`, a write-capable `CartApi`, read-only navigation + * (`currentEntry` only — `navigate`/`back` are rejected host-side), and the + * `ResolutionApi` describing the validation to resolve. A future `paymentType` + * event would receive read-only cart instead, documented here for forward + * compatibility. + */ + 'pos.resolution.action.render': RenderExtension< + StandardApi<'pos.resolution.action.render'> & + CartApi & + ReadonlyNavigationApi & + ResolutionApi, + BasicComponents + >; /** * Renders a single interactive button component as a menu item in the post-return action menu. Use this target for post-return operations like generating return receipts, processing restocking workflows, or collecting return feedback. * diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts index bae46a34dc..d59fa86440 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts @@ -5,6 +5,17 @@ import type { ShopifyInterceptor, } from './events'; +// TODO(prototype): The `navigation` global is declared process-wide for all POS +// targets. For `pos.resolution.action.render` we want read-only navigation +// (currentEntry only, no navigate/back). This is currently expressed in the +// per-target API intersection via `ReadonlyNavigationApi`, but the `navigation` +// *global* still advertises the full `Navigation` type. Per-target global +// narrowing (following the BackgroundShopifyGlobal precedent + buildTargetDts +// isDataTarget branch) is deferred — navigation writes are rejected host-side +// in Shopify/extensibility, not at the type level. A future PR can add a +// `ResolutionShopifyGlobal` / narrowed navigation global if type-level +// enforcement is needed. + /** * The `shopify` global provides APIs that are available to all POS extensions * without needing to access them through the target's `api` argument. From cb56ae909e1e7c398a7608c41ac35b5298d7f34f Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Wed, 29 Jul 2026 11:28:13 -0700 Subject: [PATCH 2/5] WIP: drop ResolutionApi, live cart + read-only nav URL handle --- .changeset/pos-resolution-target.md | 2 +- .../src/point-of-sale/factories.ts | 22 ++----- .../src/surfaces/point-of-sale/api.ts | 4 -- .../api/resolution-api/resolution-api.ts | 41 ------------ .../point-of-sale/extension-targets.ts | 62 ++++++++++++------- 5 files changed, 47 insertions(+), 84 deletions(-) delete mode 100644 packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts diff --git a/.changeset/pos-resolution-target.md b/.changeset/pos-resolution-target.md index 00b1fc9158..8ba354e009 100644 --- a/.changeset/pos-resolution-target.md +++ b/.changeset/pos-resolution-target.md @@ -3,4 +3,4 @@ '@shopify/ui-extensions-tester': minor --- -Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes the standard API, a write-capable Cart API, read-only navigation (`currentEntry` only — `navigate`/`back` are rejected host-side), and a new `ResolutionApi` that exposes the validation handle, severity, and message. +Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes the standard API, a write-capable live Cart API, and read-only navigation (`currentEntry` only — `navigate`/`back` throw host-side). The app identifies which validation to resolve by reading the handle from the seeded navigation URL (`navigation.currentEntry.url`), and re-runs its own interceptor validation function against the live `cart.current` to regenerate the violation details. diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index a7e0d2b00f..4e27ab99d6 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -26,7 +26,6 @@ import type { CashTrackingSessionCompleteData, CartUpdateEventData, Money, - ResolutionApi, ReadonlyNavigationApi, } from '@shopify/ui-extensions/point-of-sale'; @@ -258,21 +257,12 @@ function createMockCashDrawerApi(): CashDrawerApi { return {cashDrawer: {open: async () => {}}}; } -function createMockResolutionApi(): ResolutionApi { - return { - resolution: { - event: 'beforeCheckout', - handle: 'mock-handle', - level: 'error', - message: 'This cart requires resolution before checkout.', - }, - }; -} - function createMockReadonlyNavigationApi(): ReadonlyNavigationApi { return { currentEntry: { key: 'mock-key', + // The URL is seeded as `/{handle}` by the host. Tests can override + // this by constructing their own mock and replacing `currentEntry`. url: '/mock-handle', getState: () => null, }, @@ -474,18 +464,16 @@ function createActionTargetCashDrawerMock( }; } -// Group R: StandardApi + CartApi + ReadonlyNavigationApi + ResolutionApi +// Group R: StandardApi + CartApi + ReadonlyNavigationApi function createResolutionTargetMock( target: T, ): StandardApi & CartApi & - ReadonlyNavigationApi & - ResolutionApi { + ReadonlyNavigationApi { return { ...createMockStandardApi(target), ...createMockCartApi(), ...createMockReadonlyNavigationApi(), - ...createMockResolutionApi(), }; } @@ -658,7 +646,7 @@ const posMockFactories: PosMockFactory = { // Group Q: ActionTargetApi + CashDrawerApi 'pos.register-details.action.render': createActionTargetCashDrawerMock, - // Group R: StandardApi + CartApi + ReadonlyNavigationApi + ResolutionApi + // Group R: StandardApi + CartApi + ReadonlyNavigationApi 'pos.resolution.action.render': createResolutionTargetMock, // Data targets diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts index 721140e164..e793d5c174 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -20,10 +20,6 @@ export type { ExtensionApiContent, } from './api/extension-api/extension-api'; export type {ActionTargetApi} from './api/action-target-api/action-target-api'; -export type { - ResolutionApi, - ResolutionApiContent, -} from './api/resolution-api/resolution-api'; export type { ReadonlyNavigationApi, Navigation, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts deleted file mode 100644 index dec42e5112..0000000000 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api/resolution-api/resolution-api.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Describes the validation that the resolution screen is resolving. - * @publicDocs - */ -export interface ResolutionApiContent { - /** - * The intercept event being resolved. - */ - event: 'beforeCheckout'; - /** - * The app-authored handle of the validation this screen is resolving. - * Raw, NOT namespaced. - */ - handle: string; - /** - * Severity of the validation being resolved. - */ - level: 'error' | 'warning' | 'info'; - /** - * The message POS would have shown natively. - */ - message: string; - /** - * Optional pointer to what the validation applies to, e.g. a line item. - */ - targetPath?: string; - /** - * App-supplied metafields carried through from the intercept result. - */ - metafields?: Record; -} - -/** - * Provides access to the resolution context for a `pos.resolution.action.render` - * extension target. The `resolution` property describes the validation that - * this screen is resolving. - * @publicDocs - */ -export interface ResolutionApi { - resolution: ResolutionApiContent; -} diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts index a3de7cab60..2be0a75ca3 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts @@ -25,7 +25,6 @@ import type { OrderApi, StorageApi, CashDrawerApi, - ResolutionApi, ReadonlyNavigationApi, } from './api'; import type {ActionExtensionComponents} from './components/targets/ActionExtensionComponents'; @@ -127,36 +126,57 @@ export interface RenderExtensionTargets { /** * Renders a resolution side panel beside the POS cart when a merchant app's * `beforeCheckout` intercept returns a blocking validation. POS launches this - * target automatically — it is not triggered by a tile or menu item — and the - * extension receives the validation's handle, severity, and message through - * the Resolution API. - * - * The extension uses the Cart API (read + write) to let the merchant resolve - * the validation (e.g. remove a restricted item, apply a required discount). - * Navigation is read-only: `currentEntry` is available so the extension can - * read its URL-seeded handle, but `navigate()` and `back()` are rejected by - * the host. + * target automatically — it is not triggered by a tile or menu item — and + * revalidates the cart when the side panel is closed. + * + * **Which validation are you resolving?** POS seeds the navigation URL with + * `/{handle}`, where `handle` is the one your app supplied on the blocking + * validation. Read it from `navigation.currentEntry`: + * + * ```ts + * const handle = navigation.currentEntry.url?.slice(1) ?? ''; + * ``` + * + * **Getting the validation details.** There is no API that hands you the + * violation — this is deliberate, so there is one source of truth for + * validation logic. Re-run the same validation function you use in your + * `beforeCheckout` interceptor against `shopify.cart.current`, then match on + * the handle to find the violation this screen is for: + * + * ```ts + * const violations = myValidations(shopify.cart.current); + * const violation = violations.find((v) => v.handle === handle); + * ``` + * + * **`cart.current` is live.** The cart is the same live cart every other + * target sees — read and write. Mutate it to resolve the problem (remove a + * restricted item, apply a required discount, etc.), observe the change + * reflected in `cart.current`, then re-run your validation function to + * confirm it passes and render your 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. * * **Static per-event API table** (API exposure is static per intercept event * name, documented here — there is no runtime capability introspection): * - * | Event | Cart | Navigation | Resolution | Standard | - * | --- | --- | --- | --- | --- | - * | `beforeCheckout` | read + write | read-only | yes | yes | - * | `paymentType` *(future, not implemented)* | read-only | read-only | yes | yes | + * | Event | Cart | Navigation | Standard | + * | --- | --- | --- | --- | + * | `beforeCheckout` | read + write | read-only | yes | + * | `paymentType` *(future, not implemented)* | read-only | read-only | yes | * * For the current `beforeCheckout` event the extension gets: the full - * `StandardApi`, a write-capable `CartApi`, read-only navigation - * (`currentEntry` only — `navigate`/`back` are rejected host-side), and the - * `ResolutionApi` describing the validation to resolve. A future `paymentType` - * event would receive read-only cart instead, documented here for forward - * compatibility. + * `StandardApi`, a write-capable `CartApi`, and read-only navigation + * (`currentEntry` only — `navigate`/`back` throw host-side). A future + * `paymentType` event would receive read-only cart instead, documented here + * for forward compatibility. */ 'pos.resolution.action.render': RenderExtension< StandardApi<'pos.resolution.action.render'> & CartApi & - ReadonlyNavigationApi & - ResolutionApi, + ReadonlyNavigationApi, BasicComponents >; /** From 86be0edbd4bbdc9677f298b4962da09d07e6e585 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Wed, 29 Jul 2026 11:43:33 -0700 Subject: [PATCH 3/5] Use ActionTargetApi (includes ScannerApi) for pos.resolution.action.render Per Henry: resolution should have scanner and all the action APIs. ActionTargetApi = {extensionPoint: T} & StandardApi & 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 --- .changeset/pos-resolution-target.md | 2 +- .../src/point-of-sale/factories.ts | 8 +++--- .../point-of-sale/extension-targets.ts | 25 ++++++++++++------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.changeset/pos-resolution-target.md b/.changeset/pos-resolution-target.md index 8ba354e009..e4553a1407 100644 --- a/.changeset/pos-resolution-target.md +++ b/.changeset/pos-resolution-target.md @@ -3,4 +3,4 @@ '@shopify/ui-extensions-tester': minor --- -Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes the standard API, a write-capable live Cart API, and read-only navigation (`currentEntry` only — `navigate`/`back` throw host-side). The app identifies which validation to resolve by reading the handle from the seeded navigation URL (`navigation.currentEntry.url`), and re-runs its own interceptor validation function against the live `cart.current` to regenerate the violation details. +Add the `pos.resolution.action.render` extension target for POS. This target renders a resolution side panel beside the cart when a `beforeCheckout` intercept returns a blocking validation. The target's API includes `ActionTargetApi` (standard API + scanner), a write-capable live Cart API, and read-only navigation (`currentEntry` only — `navigate`/`back` throw host-side). The app identifies which validation to resolve by reading the handle from the seeded navigation URL (`navigation.currentEntry.url`), and re-runs its own interceptor validation function against the live `cart.current` to regenerate the violation details. diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index 4e27ab99d6..eb58e388d3 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -464,14 +464,14 @@ function createActionTargetCashDrawerMock( }; } -// Group R: StandardApi + CartApi + ReadonlyNavigationApi +// Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi function createResolutionTargetMock( target: T, -): StandardApi & +): ActionTargetApi & CartApi & ReadonlyNavigationApi { return { - ...createMockStandardApi(target), + ...createMockActionTargetApi(target), ...createMockCartApi(), ...createMockReadonlyNavigationApi(), }; @@ -646,7 +646,7 @@ const posMockFactories: PosMockFactory = { // Group Q: ActionTargetApi + CashDrawerApi 'pos.register-details.action.render': createActionTargetCashDrawerMock, - // Group R: StandardApi + CartApi + ReadonlyNavigationApi + // Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi 'pos.resolution.action.render': createResolutionTargetMock, // Data targets diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts index 2be0a75ca3..0ad99ada41 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/extension-targets.ts @@ -162,19 +162,26 @@ export interface RenderExtensionTargets { * **Static per-event API table** (API exposure is static per intercept event * name, documented here — there is no runtime capability introspection): * - * | Event | Cart | Navigation | Standard | - * | --- | --- | --- | --- | - * | `beforeCheckout` | read + write | read-only | yes | - * | `paymentType` *(future, not implemented)* | read-only | read-only | yes | + * | 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`, a write-capable `CartApi`, and read-only navigation - * (`currentEntry` only — `navigate`/`back` throw host-side). A future - * `paymentType` event would receive read-only cart instead, documented here - * for forward compatibility. + * `StandardApi` (including `ScannerApi` — scanning a driver's licence or ID + * to clear an age-restriction block is a first-class use of this target), a + * write-capable `CartApi`, and read-only navigation (`currentEntry` only — + * `navigate`/`back` throw host-side). A future `paymentType` event would + * receive read-only cart instead, documented here for forward compatibility. + * + * **API version requirement.** The handle travels in `navigation.currentEntry`, + * which the host only exposes on remote-dom api versions. This target therefore + * requires a remote-dom `minimum_api_version`; version enforcement happens in + * the shop/world server registration (`pos_ui.rb`), not in this package. Because + * the target is brand new, nothing is grandfathered. */ 'pos.resolution.action.render': RenderExtension< - StandardApi<'pos.resolution.action.render'> & + ActionTargetApi<'pos.resolution.action.render'> & CartApi & ReadonlyNavigationApi, BasicComponents From 7bc12e9ec0ea1b53d48fae87dda34258112a8a2c Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Wed, 29 Jul 2026 12:31:59 -0700 Subject: [PATCH 4/5] Define ReadonlyNavigation as a Pick of Navigation; nest under navigation key - ReadonlyNavigation = Pick 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. --- .../src/point-of-sale/factories.ts | 18 ++--- .../src/surfaces/point-of-sale/api.ts | 1 + .../api/navigation-api/navigation-api.ts | 70 ++++++++----------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index eb58e388d3..6e9e138de6 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -259,15 +259,17 @@ function createMockCashDrawerApi(): CashDrawerApi { function createMockReadonlyNavigationApi(): ReadonlyNavigationApi { return { - currentEntry: { - key: 'mock-key', - // The URL is seeded as `/{handle}` by the host. Tests can override - // this by constructing their own mock and replacing `currentEntry`. - url: '/mock-handle', - getState: () => null, + navigation: { + currentEntry: { + key: 'mock-key', + // The URL is seeded as `/{handle}` by the host. Tests can override + // this by constructing their own mock and replacing `navigation`. + url: '/mock-handle', + getState: () => null, + }, + addEventListener: () => {}, + removeEventListener: () => {}, }, - addEventListener: () => {}, - removeEventListener: () => {}, }; } diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts index e793d5c174..3ed88ce08c 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api.ts @@ -21,6 +21,7 @@ export type { } from './api/extension-api/extension-api'; export type {ActionTargetApi} from './api/action-target-api/action-target-api'; export type { + ReadonlyNavigation, ReadonlyNavigationApi, Navigation, NavigationNavigateOptions, diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts index ca5641a020..312fdd24c9 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/api/navigation-api/navigation-api.ts @@ -39,42 +39,6 @@ export interface NavigationCurrentEntryChangeEvent { from: NavigationHistoryEntry; } -/** - * Read-only view of the `Navigation` object for extension targets that should - * not be able to navigate programmatically (e.g. `pos.resolution.action.render`). - * Exposes `currentEntry` and navigation event listeners but omits `navigate` - * and `back`. - * - * Note: `currentEntry` retains its writable `Signal` type intentionally — the - * write path is rejected host-side, not at the type level. - * - * @publicDocs - */ -export interface ReadonlyNavigationApi { - /** - * Returns a `NavigationHistoryEntry` object representing the location the user is currently navigated to. Use to access current URL, navigation state, or implement navigation-aware functionality based on the current location. - */ - currentEntry: NavigationHistoryEntry; - /** - * Registers an event listener for navigation events. The `currententrychange` event fires when the `currentEntry` property changes, such as when the user navigates to a different screen within the extension modal. Use to track navigation changes, update UI state based on the current location, or implement analytics for navigation patterns. - * @param type - The event type to listen for. Currently only `'currententrychange'` is supported. - * @param cb - The callback function invoked when the event fires. Receives a `NavigationCurrentEntryChangeEvent` containing the previous entry that was navigated away from. - */ - addEventListener( - type: 'currententrychange', - cb: (event: NavigationCurrentEntryChangeEvent) => void, - ): void; - /** - * Removes a previously registered event listener. The callback reference must match the one passed to `addEventListener`. Use to clean up event listeners when they are no longer needed, such as when a component unmounts or navigation tracking should be disabled. - * @param type - The event type to remove the listener for. Currently only `'currententrychange'` is supported. - * @param cb - The callback function to remove. Must be the same function reference that was passed to `addEventListener`. - */ - removeEventListener( - type: 'currententrychange', - cb: (event: NavigationCurrentEntryChangeEvent) => void, - ): void; -} - /** * The `Navigation` object provides navigation controls for extension modals. * @publicDocs @@ -113,14 +77,36 @@ export interface Navigation { } /** - * The global `window` object provides control over the extension modal lifecycle. Access these properties and methods directly through the global `window` object to manage the modal interface programmatically. + * Read-only view of the `Navigation` object, for extension targets that must not + * navigate programmatically (e.g. `pos.resolution.action.render`). + * + * This is a `Pick` of `Navigation` rather than a redeclaration, so it stays in + * sync automatically as `Navigation` evolves: any member added to `Navigation` + * is excluded here by default, which is the safe direction for a read-only view. + * + * `navigate` and `back` are omitted. The host rejects navigation writes at + * runtime regardless of the type (see Shopify/extensibility#1586); this type + * exists so the omission is visible to app developers in autocomplete. + * + * Note: `currentEntry` intentionally keeps the same type it has on `Navigation`. + * The RPC bridge already makes signals read-only guest-side, so no separate + * read-only entry type is needed. + * * @publicDocs */ -export interface Window { - /** - * Closes the extension screen and dismisses the modal interface. Use to programmatically close the modal after completing a workflow, canceling an operation, or when user action is no longer required. This provides the same behavior as the user dismissing the modal through the UI. - */ - close(): void; +export type ReadonlyNavigation = Pick< + Navigation, + 'currentEntry' | 'addEventListener' | 'removeEventListener' +>; + +/** + * Provides read-only navigation for targets that cannot navigate + * programmatically. Follows the same shape convention as `CartApi` / + * `ReadonlyCartApi`. + * @publicDocs + */ +export interface ReadonlyNavigationApi { + navigation: ReadonlyNavigation; } /** From cb895c911617eb33d523f3df6b0a72d7ceeaefb0 Mon Sep 17 00:00:00 2001 From: Henry Stelle Date: Wed, 29 Jul 2026 15:19:16 -0700 Subject: [PATCH 5/5] Fix lint on the 2026-07 line 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. --- packages/ui-extensions-tester/src/point-of-sale/factories.ts | 4 +--- packages/ui-extensions/src/surfaces/point-of-sale/globals.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index 6e9e138de6..3a88f62daf 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -469,9 +469,7 @@ function createActionTargetCashDrawerMock( // Group R: ActionTargetApi + CartApi + ReadonlyNavigationApi function createResolutionTargetMock( target: T, -): ActionTargetApi & - CartApi & - ReadonlyNavigationApi { +): ActionTargetApi & CartApi & ReadonlyNavigationApi { return { ...createMockActionTargetApi(target), ...createMockCartApi(), diff --git a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts index d59fa86440..3f590a103d 100644 --- a/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts +++ b/packages/ui-extensions/src/surfaces/point-of-sale/globals.ts @@ -5,6 +5,7 @@ import type { ShopifyInterceptor, } from './events'; +/* eslint-disable-next-line no-warning-comments -- deliberate prototype marker; tracks a known gap in this draft */ // TODO(prototype): The `navigation` global is declared process-wide for all POS // targets. For `pos.resolution.action.render` we want read-only navigation // (currentEntry only, no navigate/back). This is currently expressed in the