From f1b0482d71cdf3c5103d6b40e5a4b67a169b4725 Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Mon, 14 Sep 2026 13:33:26 +0100 Subject: [PATCH 1/3] Support optional owner_type in UI extension metafields --- .changeset/calm-ui-metafield-owners.md | 5 +++ .../app/src/cli/models/app/loader.test.ts | 44 ++++++++++++++----- .../src/cli/models/extensions/schemas.test.ts | 28 +++++++++++- .../app/src/cli/models/extensions/schemas.ts | 11 +++++ .../specifications/ui_extension.test.ts | 34 ++++++++++++-- 5 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 .changeset/calm-ui-metafield-owners.md diff --git a/.changeset/calm-ui-metafield-owners.md b/.changeset/calm-ui-metafield-owners.md new file mode 100644 index 00000000000..ac50c98f423 --- /dev/null +++ b/.changeset/calm-ui-metafield-owners.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Support owner types in UI extension metafield configuration. diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index f402aff0bb2..09503ba1461 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -1345,7 +1345,7 @@ describe('load', () => { await writeConfig(appConfiguration) const blockConfiguration = ` - api_version = "unstable" + api_version = "2026-10" [[extensions]] name = "my-admin-action" handle = "admin-action-handle" @@ -1356,6 +1356,11 @@ describe('load', () => { [[extensions.metafields]] namespace = "my-namespace" key = "my-key" + owner_type = "PRODUCT" + + [[extensions.metafields]] + namespace = "my-namespace" + key = "my-key-without-owner-type" # extra fields not included in the schema should be ignored [[extensions.invalid_field]] @@ -1384,7 +1389,7 @@ describe('load', () => { expect(extension).not.toBeUndefined() if (extension) { expect(extension.configuration).toMatchObject({ - api_version: 'unstable', + api_version: '2026-10', name: 'my-admin-action', handle: 'admin-action-handle', type: 'ui_extension', @@ -1392,6 +1397,11 @@ describe('load', () => { { namespace: 'my-namespace', key: 'my-key', + owner_type: 'PRODUCT', + }, + { + namespace: 'my-namespace', + key: 'my-key-without-owner-type', }, ], extension_points: [ @@ -1400,6 +1410,11 @@ describe('load', () => { { namespace: 'my-namespace', key: 'my-key', + owner_type: 'PRODUCT', + }, + { + namespace: 'my-namespace', + key: 'my-key-without-owner-type', }, ], module: './src/ActionExtension.js', @@ -1421,7 +1436,7 @@ describe('load', () => { await writeConfig(appConfiguration) const blockConfiguration = ` - api_version = "2023-07" + api_version = "2026-10" [[extensions]] name = "My checkout extension" @@ -1465,6 +1480,11 @@ describe('load', () => { target = "purchase.checkout.block.render" module = "./CheckoutDynamicRender.jsx" + [[extensions.targeting.metafields]] + namespace = "target-namespace" + key = "target-key" + owner_type = "CART" + # extra fields not included in the schema should be ignored [[extensions.invalid_field]] namespace = "my-namespace" @@ -1502,7 +1522,7 @@ describe('load', () => { expect(extension).not.toBeUndefined() if (extension) { expect(extension.configuration).toMatchObject({ - api_version: '2023-07', + api_version: '2026-10', name: 'My checkout extension', handle: 'checkout-ui', type: 'ui_extension', @@ -1559,12 +1579,9 @@ describe('load', () => { { metafields: [ { - key: 'my-key', - namespace: 'my-namespace', - }, - { - key: 'my-other-key', - namespace: 'my-namespace', + key: 'target-key', + namespace: 'target-namespace', + owner_type: 'CART', }, ], module: './CheckoutDynamicRender.jsx', @@ -1575,6 +1592,13 @@ describe('load', () => { { target: 'purchase.checkout.block.render', module: './CheckoutDynamicRender.jsx', + metafields: [ + { + key: 'target-key', + namespace: 'target-namespace', + owner_type: 'CART', + }, + ], }, ], }) diff --git a/packages/app/src/cli/models/extensions/schemas.test.ts b/packages/app/src/cli/models/extensions/schemas.test.ts index d149d24b791..02cf3fb0df8 100644 --- a/packages/app/src/cli/models/extensions/schemas.test.ts +++ b/packages/app/src/cli/models/extensions/schemas.test.ts @@ -1,4 +1,4 @@ -import {BaseSchema, MAX_UID_LENGTH, NewExtensionPointsSchema} from './schemas.js' +import {BaseSchema, MAX_UID_LENGTH, MetafieldSchema, NewExtensionPointsSchema} from './schemas.js' import {describe, expect, test} from 'vitest' const validUIDTestCases = [ @@ -56,6 +56,32 @@ describe('UIDSchema', () => { }) }) +describe('MetafieldSchema', () => { + test.each(['COMPANY', 'COMPANY_LOCATION', 'CUSTOMER', 'CART', 'PRODUCT', 'PRODUCTVARIANT', 'SHOP'])( + 'accepts %s as an owner type', + (ownerType) => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: ownerType}) + + expect(result).toEqual({ + success: true, + data: {namespace: 'custom', key: 'value', owner_type: ownerType}, + }) + }, + ) + + test('accepts a metafield without an owner type', () => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value'}) + + expect(result.success).toBe(true) + }) + + test('rejects an unsupported owner type', () => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: 'ORDER'}) + + expect(result.success).toBe(false) + }) +}) + describe('NewExtensionPointsSchema', () => { test.each([ [ diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index 11d6f2fdc7e..c0142dec5ec 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -6,9 +6,20 @@ export const MAX_UID_LENGTH = 250 // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ZodSchemaType = zod.ZodType +const MetafieldOwnerTypeSchema = zod.enum([ + 'COMPANY', + 'COMPANY_LOCATION', + 'CUSTOMER', + 'CART', + 'PRODUCT', + 'PRODUCTVARIANT', + 'SHOP', +]) + export const MetafieldSchema = zod.object({ namespace: zod.string(), key: zod.string(), + owner_type: MetafieldOwnerTypeSchema.optional(), }) const CollectBuyerConsentCapabilitySchema = zod.object({ diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index 14aa354b05c..ce3a8a62ca7 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -117,12 +117,12 @@ describe('ui_extension', async () => { }, }, ], - api_version: '2023-01' as const, + api_version: '2026-10' as const, handle: 'test-ui-extension', name: 'UI Extension', description: 'This is an ordinary test extension', type: 'ui_extension', - metafields: [{namespace: 'test', key: 'test'}], + metafields: [{namespace: 'test', key: 'test', owner_type: 'PRODUCT' as const}], capabilities: { block_progress: false, network_access: false, @@ -155,7 +155,7 @@ describe('ui_extension', async () => { intents: undefined, assets: undefined, module: './src/ExtensionPointA.js', - metafields: [{namespace: 'test', key: 'test'}], + metafields: [{namespace: 'test', key: 'test', owner_type: 'PRODUCT'}], default_placement_reference: undefined, capabilities: undefined, preloads: {}, @@ -176,6 +176,34 @@ describe('ui_extension', async () => { ]) }) + test('target-level metafields override extension-level metafields', async () => { + const allSpecs = await loadLocalExtensionsSpecifications() + const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! + const configuration = { + targeting: [ + { + target: 'EXTENSION::POINT::A', + module: './src/ExtensionPointA.js', + metafields: [{namespace: 'target', key: 'value', owner_type: 'COMPANY_LOCATION' as const}], + }, + ], + api_version: '2026-10' as const, + handle: 'test-ui-extension', + name: 'UI Extension', + type: 'ui_extension', + metafields: [{namespace: 'extension', key: 'value', owner_type: 'SHOP' as const}], + } + + const parsed = specification.parseConfigurationObject(configuration) + + expect(parsed.state).toBe('ok') + if (parsed.state === 'ok') { + expect(parsed.data.extension_points[0]?.metafields).toStrictEqual([ + {namespace: 'target', key: 'value', owner_type: 'COMPANY_LOCATION'}, + ]) + } + }) + test('targeting object accepts a default_placement', async () => { const allSpecs = await loadLocalExtensionsSpecifications() const specification = allSpecs.find((spec) => spec.identifier === 'ui_extension')! From 16e6731dc1f2d3c8280a628cb8bb6a7df5bc6340 Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Mon, 14 Sep 2026 17:25:13 +0100 Subject: [PATCH 2/3] Bump as minor change for this user-facing, non-breaking change --- .changeset/calm-ui-metafield-owners.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-ui-metafield-owners.md b/.changeset/calm-ui-metafield-owners.md index ac50c98f423..ac6fd116b19 100644 --- a/.changeset/calm-ui-metafield-owners.md +++ b/.changeset/calm-ui-metafield-owners.md @@ -1,5 +1,5 @@ --- -'@shopify/app': patch +'@shopify/app': minor --- Support owner types in UI extension metafield configuration. From 050709b25394adf438efdab734e319a8b0f24e9e Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Tue, 15 Sep 2026 09:49:27 +0100 Subject: [PATCH 3/3] Loosen metafield owner_type validation since the server will validate it --- .../src/cli/models/extensions/schemas.test.ts | 23 ++++++------------- .../app/src/cli/models/extensions/schemas.ts | 12 +--------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/packages/app/src/cli/models/extensions/schemas.test.ts b/packages/app/src/cli/models/extensions/schemas.test.ts index 02cf3fb0df8..9ba7b152c2d 100644 --- a/packages/app/src/cli/models/extensions/schemas.test.ts +++ b/packages/app/src/cli/models/extensions/schemas.test.ts @@ -57,29 +57,20 @@ describe('UIDSchema', () => { }) describe('MetafieldSchema', () => { - test.each(['COMPANY', 'COMPANY_LOCATION', 'CUSTOMER', 'CART', 'PRODUCT', 'PRODUCTVARIANT', 'SHOP'])( - 'accepts %s as an owner type', - (ownerType) => { - const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: ownerType}) + test('accepts an owner type for API validation', () => { + const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: 'PRODUCT'}) - expect(result).toEqual({ - success: true, - data: {namespace: 'custom', key: 'value', owner_type: ownerType}, - }) - }, - ) + expect(result).toEqual({ + success: true, + data: {namespace: 'custom', key: 'value', owner_type: 'PRODUCT'}, + }) + }) test('accepts a metafield without an owner type', () => { const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value'}) expect(result.success).toBe(true) }) - - test('rejects an unsupported owner type', () => { - const result = MetafieldSchema.safeParse({namespace: 'custom', key: 'value', owner_type: 'ORDER'}) - - expect(result.success).toBe(false) - }) }) describe('NewExtensionPointsSchema', () => { diff --git a/packages/app/src/cli/models/extensions/schemas.ts b/packages/app/src/cli/models/extensions/schemas.ts index c0142dec5ec..4c32cf3eb7c 100644 --- a/packages/app/src/cli/models/extensions/schemas.ts +++ b/packages/app/src/cli/models/extensions/schemas.ts @@ -6,20 +6,10 @@ export const MAX_UID_LENGTH = 250 // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ZodSchemaType = zod.ZodType -const MetafieldOwnerTypeSchema = zod.enum([ - 'COMPANY', - 'COMPANY_LOCATION', - 'CUSTOMER', - 'CART', - 'PRODUCT', - 'PRODUCTVARIANT', - 'SHOP', -]) - export const MetafieldSchema = zod.object({ namespace: zod.string(), key: zod.string(), - owner_type: MetafieldOwnerTypeSchema.optional(), + owner_type: zod.string().optional(), }) const CollectBuyerConsentCapabilitySchema = zod.object({