Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 142 additions & 18 deletions packages/app/src/cli/models/app/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './app.test-data.js'
import {ExtensionInstance} from '../extensions/extension-instance.js'
import {FunctionConfigType} from '../extensions/specifications/function.js'
import {addTypeDefinition} from '../extensions/specifications/type-generation.js'
import {WebhooksConfig} from '../extensions/specifications/types/app_config_webhook.js'
import {EditorExtensionCollectionType} from '../extensions/specifications/editor_extension_collection.js'
import {ApplicationURLs} from '../../services/dev/urls.js'
Expand Down Expand Up @@ -960,6 +961,27 @@ describe('generateExtensionTypes', () => {

await mkdir(ext1Dir)
await mkdir(ext2Dir)
await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render'))
await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render'))
await writeFile(
joinPath(
tmpDir,
'node_modules',
'@shopify',
'ui-extensions',
'admin.product-details.action.render',
'index.js',
),
'// render target',
)
await writeFile(
joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render', 'index.js'),
'// orders target',
)
await writeFile(joinPath(ext1Dir, 'ext1-module-1.jsx'), 'export {}')
await writeFile(joinPath(ext1Dir, 'ext1-module-2.jsx'), 'export {}')
await writeFile(joinPath(ext2Dir, 'ext2-module-1.jsx'), 'export {}')
await writeFile(joinPath(ext2Dir, 'ext2-module-2.jsx'), 'export {}')

const uiExtension1 = await testUIExtension({type: 'ui_extension', handle: 'ext1', directory: ext1Dir})
const uiExtension2 = await testUIExtension({type: 'ui_extension', handle: 'ext2', directory: ext2Dir})
Expand All @@ -971,22 +993,32 @@ describe('generateExtensionTypes', () => {

// Mock the extension contributions
vi.spyOn(uiExtension1, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => {
typeDefinitionsByFile.set(
joinPath(ext1Dir, 'shopify.d.ts'),
new Set([
"declare module './ext1-module-1.jsx' { // mocked ext1 module 1 definition }",
"declare module './ext1-module-2.jsx' { // mocked ext1 module 2 definition }",
]),
)
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(ext1Dir, 'ext1-module-1.jsx'),
typeFilePath: joinPath(ext1Dir, 'shopify.d.ts'),
targets: ['admin.product-details.action.render'],
apiVersion: '2025-10',
})
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(ext1Dir, 'ext1-module-2.jsx'),
typeFilePath: joinPath(ext1Dir, 'shopify.d.ts'),
targets: ['admin.product-details.action.render'],
apiVersion: '2025-10',
})
})
vi.spyOn(uiExtension2, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => {
typeDefinitionsByFile.set(
joinPath(ext2Dir, 'shopify.d.ts'),
new Set([
"declare module './ext2-module-1.jsx' { // mocked ext2 module 1 definition }",
"declare module './ext2-module-2.jsx' { // mocked ext2 module 2 definition }",
]),
)
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(ext2Dir, 'ext2-module-1.jsx'),
typeFilePath: joinPath(ext2Dir, 'shopify.d.ts'),
targets: ['admin.orders-details.block.render'],
apiVersion: '2025-10',
})
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(ext2Dir, 'ext2-module-2.jsx'),
typeFilePath: joinPath(ext2Dir, 'shopify.d.ts'),
targets: ['admin.orders-details.block.render'],
apiVersion: '2025-10',
})
})

// When
Expand All @@ -997,15 +1029,107 @@ describe('generateExtensionTypes', () => {
const ext1FileContent = await readFile(ext1TypeFilePath)
const normalizedExt1Content = ext1FileContent.toString().replace(/\\/g, '/')
expect(normalizedExt1Content).toBe(`import '@shopify/ui-extensions';\n
declare module './ext1-module-1.jsx' { // mocked ext1 module 1 definition }
declare module './ext1-module-2.jsx' { // mocked ext1 module 2 definition }`)
//@ts-ignore
declare module './ext1-module-1.jsx' {
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}

//@ts-ignore
declare module './ext1-module-2.jsx' {
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}
`)

const ext2TypeFilePath = joinPath(ext2Dir, 'shopify.d.ts')
const ext2FileContent = await readFile(ext2TypeFilePath)
const normalizedExt2Content = ext2FileContent.toString().replace(/\\/g, '/')
expect(normalizedExt2Content).toBe(`import '@shopify/ui-extensions';\n
declare module './ext2-module-1.jsx' { // mocked ext2 module 1 definition }
declare module './ext2-module-2.jsx' { // mocked ext2 module 2 definition }`)
//@ts-ignore
declare module './ext2-module-1.jsx' {
const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api;
const globalThis: { shopify: typeof shopify };
}

//@ts-ignore
declare module './ext2-module-2.jsx' {
const shopify: import('@shopify/ui-extensions/admin.orders-details.block.render').Api;
const globalThis: { shopify: typeof shopify };
}
`)
})
})

test('merges shared file targets across multiple UI extensions into one declaration', async () => {
await inTemporaryDirectory(async (tmpDir) => {
const ext1Dir = joinPath(tmpDir, 'extensions', 'ext1')
const ext2Dir = joinPath(tmpDir, 'extensions', 'ext2')
const sharedDir = joinPath(tmpDir, 'shared')

await mkdir(ext1Dir)
await mkdir(ext2Dir)
await mkdir(sharedDir)
await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.product-details.action.render'))
await mkdir(joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render'))
await writeFile(
joinPath(
tmpDir,
'node_modules',
'@shopify',
'ui-extensions',
'admin.product-details.action.render',
'index.js',
),
'// render target',
)
await writeFile(
joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions', 'admin.orders-details.block.render', 'index.js'),
'// orders target',
)
await writeFile(joinPath(sharedDir, 'utils.js'), 'export {}')

const uiExtension1 = await testUIExtension({type: 'ui_extension', handle: 'ext1', directory: ext1Dir})
const uiExtension2 = await testUIExtension({type: 'ui_extension', handle: 'ext2', directory: ext2Dir})

const app = testApp({
directory: tmpDir,
allExtensions: [uiExtension1, uiExtension2],
})

vi.spyOn(uiExtension1, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => {
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(sharedDir, 'utils.js'),
typeFilePath: joinPath(tmpDir, 'shopify.d.ts'),
targets: ['admin.product-details.action.render'],
apiVersion: '2025-10',
})
})

vi.spyOn(uiExtension2, 'contributeToSharedTypeFile').mockImplementation(async (typeDefinitionsByFile) => {
addTypeDefinition(typeDefinitionsByFile, {
fullPath: joinPath(sharedDir, 'utils.js'),
typeFilePath: joinPath(tmpDir, 'shopify.d.ts'),
targets: ['admin.orders-details.block.render'],
apiVersion: '2025-10',
})
})

await app.generateExtensionTypes()

const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')
const fileContent = await readFile(shopifyDtsPath)
const normalizedContent = fileContent.toString().replace(/\\/g, '/')

expect(normalizedContent).toBe(`import '@shopify/ui-extensions';\n
//@ts-ignore
declare module './shared/utils.js' {
const shopify:
| import('@shopify/ui-extensions/admin.orders-details.block.render').Api
| import('@shopify/ui-extensions/admin.product-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}
`)
})
})
})
Expand Down
45 changes: 26 additions & 19 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {ExtensionSpecification, RemoteAwareExtensionSpecification} from '../exte
import {AppConfigurationUsedByCli} from '../extensions/specifications/types/app_config.js'
import {EditorExtensionCollectionType} from '../extensions/specifications/editor_extension_collection.js'
import {UIExtensionSchema} from '../extensions/specifications/ui_extension.js'
import {renderTypeDefinitions, TypeDefinitionsByFile} from '../extensions/specifications/type-generation.js'
import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js'
import {AppAccessSpecIdentifier} from '../extensions/specifications/app_config_app_access.js'
import {WebhookSubscriptionSchema} from '../extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.js'
Expand Down Expand Up @@ -558,29 +559,35 @@ export class App<
}

async generateExtensionTypes() {
const typeDefinitionsByFile = new Map<string, Set<string>>()
const typeDefinitionsByFile: TypeDefinitionsByFile = new Map()
await Promise.all(
this.allExtensions.map((extension) => extension.contributeToSharedTypeFile(typeDefinitionsByFile)),
this.allExtensions.map((extension) =>
extension.contributeToSharedTypeFile(typeDefinitionsByFile, this.directory),
),
)
typeDefinitionsByFile.forEach((types, typeFilePath) => {
const exists = fileExistsSync(typeFilePath)
// No types to add, remove the file if it exists
if (types.size === 0) {
if (exists) {
removeFileSync(typeFilePath)

await Promise.all(
Array.from(typeDefinitionsByFile.entries()).map(async ([typeFilePath, typeDefinitions]) => {
const types = await renderTypeDefinitions(typeDefinitions, typeFilePath)
const exists = fileExistsSync(typeFilePath)
// No types to add, remove the file if it exists
if (types.size === 0) {
if (exists) {
removeFileSync(typeFilePath)
}
return
}
return
}

const originalContent = exists ? readFileSync(typeFilePath).toString() : ''
// We need this top-level import to work around the TS restriction of not allowing declaring modules with relative paths.
// This is needed to enable file-specific global type declarations.
const typeContent = [`import '@shopify/ui-extensions';\n`, ...Array.from(types)].join('\n')
if (originalContent === typeContent) {
return
}
writeFileSync(typeFilePath, typeContent)
})
const originalContent = exists ? readFileSync(typeFilePath).toString() : ''
// We need this top-level import to work around the TS restriction of not allowing declaring modules with relative paths.
// This is needed to enable file-specific global type declarations.
const typeContent = [`import '@shopify/ui-extensions';\n`, ...Array.from(types)].join('\n')
if (originalContent === typeContent) {
return
}
writeFileSync(typeFilePath, typeContent)
}),
)
}

get includeConfigOnDeploy() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js'
import {FunctionConfigType} from './specifications/function.js'
import {TypeDefinitionsByFile} from './specifications/type-generation.js'
import {ExtensionFeature, ExtensionSpecification} from './specification.js'
import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js'
import {AppHomeSpecIdentifier} from './specifications/app_config_app_home.js'
Expand Down Expand Up @@ -466,8 +467,8 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
this.specification.patchWithAppDevURLs(this.configuration, urls)
}

async contributeToSharedTypeFile(typeDefinitionsByFile: Map<string, Set<string>>) {
await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile)
async contributeToSharedTypeFile(typeDefinitionsByFile: TypeDefinitionsByFile, appDirectory?: string) {
await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile, appDirectory)
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
import {ExtensionInstance} from './extension-instance.js'
import {TypeDefinitionsByFile} from './specifications/type-generation.js'
import {blocks} from '../../constants.js'

import {Flag} from '../../utilities/developer-platform-client.js'
Expand Down Expand Up @@ -123,7 +124,8 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =

contributeToSharedTypeFile?: (
extension: ExtensionInstance<TConfiguration>,
typeDefinitionsByFile: Map<string, Set<string>>,
typeDefinitionsByFile: TypeDefinitionsByFile,
appDirectory?: string,
) => Promise<void>

/**
Expand Down
Loading
Loading