Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/shopify-global-type-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

The CLI-generated `shopify.d.ts` now types the `shopify` binding as `Api & ShopifyGlobal` (intersection) for UI extension targets whose `.d.ts` re-exports a `ShopifyGlobal` type. Existing consumers who access the target API via `shopify.*` are unaffected; new host-level APIs like `shopify.addEventListener` now type-check automatically for opt-in targets (e.g. POS background extensions). Targets that do not re-export `ShopifyGlobal` emit the same output as before.
Original file line number Diff line number Diff line change
Expand Up @@ -166,23 +166,75 @@ interface CreateTypeDefinitionOptions {
}

/**
* Builds the shopify API type based on targets and optional tools type.
* Returns null if no targets are provided.
* Returns true when the resolved target declaration file re-exports a
* `ShopifyGlobal` type. Used to decide whether the `shopify` binding should be
* typed as `Api & ShopifyGlobal` or just `Api`.
*
* Uses the TS compiler API to avoid false positives from comments or string
* literals that happen to contain the word "ShopifyGlobal".
*/
function buildShopifyType(targets: string[], toolsTypeDefinition?: string): string | null {
const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : ''
function targetExportsShopifyGlobal(targetDtsPath: string): boolean {
let content: string
try {
content = readFileSync(targetDtsPath).toString()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}

if (targets.length === 1) {
const target = targets[0] ?? ''
return `import('@shopify/ui-extensions/${target}').Api${toolsSuffix}`
const sourceFile = ts.createSourceFile(targetDtsPath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)

let found = false
const visit = (node: ts.Node): void => {
if (found) return
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
for (const specifier of node.exportClause.elements) {
// Match on the exported (public) name. For `export {ShopifyGlobal}`,
// that's specifier.name. For `export {Foo as ShopifyGlobal}`,
// specifier.name is still 'ShopifyGlobal' (the public alias); the
// internal/local name 'Foo' lives on specifier.propertyName.
if (specifier.name.text === 'ShopifyGlobal') {
found = true
return
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return found
}

if (targets.length > 1) {
const unionType = targets.map((target) => `import('@shopify/ui-extensions/${target}').Api`).join(' | ')
return `(${unionType})${toolsSuffix}`
/**
* Builds the shopify API type based on targets, their resolved .d.ts paths,
* and optional tools type.
*
* If a target re-exports `ShopifyGlobal`, the emitted type is
* `import('<target>').Api & import('<target>').ShopifyGlobal` so consumers
* retain access to both the target's data surface and host-level APIs
* (e.g. `shopify.addEventListener`). Otherwise emits just `.Api`.
*
* Returns null if no targets are provided.
*/
function buildShopifyType(
targets: string[],
resolvedTargetPaths: Map<string, string>,
toolsTypeDefinition?: string,
): string | null {
const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : ''

const typeForTarget = (target: string): string => {
const base = `import('@shopify/ui-extensions/${target}').Api`
const dtsPath = resolvedTargetPaths.get(target)
if (dtsPath && targetExportsShopifyGlobal(dtsPath)) {
return `${base} & import('@shopify/ui-extensions/${target}').ShopifyGlobal`
}
return base
}

return null
if (targets.length === 0) return null
if (targets.length === 1) return `${typeForTarget(targets[0] ?? '')}${toolsSuffix}`
return `(${targets.map(typeForTarget).join(' | ')})${toolsSuffix}`
}

export function createTypeDefinition({
Expand All @@ -193,13 +245,18 @@ export function createTypeDefinition({
toolsTypeDefinition,
}: CreateTypeDefinitionOptions): string | null {
try {
// Validate that all targets can be resolved
const resolvedTargetPaths = new Map<string, string>()

// Validate that all targets can be resolved, and capture the resolved .d.ts
// path so buildShopifyType can inspect it for ShopifyGlobal exports.
for (const target of targets) {
Comment thread
vctrchu marked this conversation as resolved.
try {
require.resolve(`@shopify/ui-extensions/${target}`, {paths: [fullPath, typeFilePath]})
const resolved = require.resolve(`@shopify/ui-extensions/${target}`, {
paths: [fullPath, typeFilePath],
})
resolvedTargetPaths.set(target, resolved)
} catch (_) {
const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10}
// Throw specific error for the target that failed, matching the original getSharedTypeDefinition behavior
throw new AbortError(
`Type reference for ${target} could not be found. You might be using the wrong @shopify/ui-extensions version.`,
`Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`,
Expand All @@ -209,7 +266,7 @@ export function createTypeDefinition({

const relativePath = relativizePath(fullPath, dirname(typeFilePath))

const shopifyType = buildShopifyType(targets, toolsTypeDefinition)
const shopifyType = buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition)
if (!shopifyType) return null

const lines = [
Expand All @@ -224,7 +281,6 @@ export function createTypeDefinition({

return lines.join('\n')
} catch (error) {
// Re-throw AbortError as-is, wrap other errors
if (error instanceof AbortError) {
throw error
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1177,12 +1177,14 @@ Please check the configuration in ${uiExtension.configurationPath}`),
shouldRenderFileContent,
apiVersion,
target = 'admin.product-details.action.render',
targetDtsContent,
}: {
tmpDir: string
fileContent: string
shouldRenderFileContent?: string
apiVersion: string
target?: string
targetDtsContent?: string
}) {
// Create extension files
const srcDir = joinPath(tmpDir, 'src')
Expand All @@ -1197,7 +1199,11 @@ Please check the configuration in ${uiExtension.configurationPath}`),

const targetPath = joinPath(nodeModulesPath, target)
await mkdir(targetPath)
await writeFile(joinPath(targetPath, 'index.js'), '// Mock UI extension target')
// `require.resolve('@shopify/ui-extensions/<target>')` resolves to this file,
// and the CLI's ShopifyGlobal detector reads whatever path require.resolve
// returned. Injecting `targetDtsContent` here lets tests exercise the
// detection branch; defaults preserve the original placeholder.
await writeFile(joinPath(targetPath, 'index.js'), targetDtsContent ?? '// Mock UI extension target')
Comment thread
vctrchu marked this conversation as resolved.

let shouldRenderFilePath
if (shouldRenderFileContent) {
Expand Down Expand Up @@ -1367,6 +1373,134 @@ Please check the configuration in ${uiExtension.configurationPath}`),
})
})

test('emits Api & ShopifyGlobal intersection when target re-exports ShopifyGlobal', async () => {
const typeDefinitionsByFile = new Map<string, Set<string>>()

await inTemporaryDirectory(async (tmpDir) => {
const {extension} = await setupUIExtensionWithNodeModules({
tmpDir,
fileContent: '// JSX code',
// Remote DOM supported version
apiVersion: '2025-10',
// The target re-exports `ShopifyGlobal` via a named export specifier,
// which is the shape the AST helper detects. Any surface can opt in
// by emitting this shape from its target `.d.ts`.
targetDtsContent: `
interface _ShopifyGlobalInternal { addEventListener(type: string, listener: (event: unknown) => void): void }
export type {_ShopifyGlobalInternal as ShopifyGlobal}
export type Api = {placeholder: true}
`,
})

// Create tsconfig.json
const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
await writeFile(tsconfigPath, '// TypeScript config')

// When
await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)

const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')

// Then — prettier wraps the long intersection onto two lines.
expect(typeDefinitionsByFile).toStrictEqual(
new Map([
[
shopifyDtsPath,
new Set([
`//@ts-ignore\ndeclare module './src/index.jsx' {
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api &
import('@shopify/ui-extensions/admin.product-details.action.render').ShopifyGlobal;
const globalThis: { shopify: typeof shopify };
}\n`,
]),
],
]),
)
})
})

test('ShopifyGlobal detection is target-agnostic — any target with the re-export opts in', async () => {
const typeDefinitionsByFile = new Map<string, Set<string>>()

await inTemporaryDirectory(async (tmpDir) => {
// A fabricated target name belonging to no real surface. The detector
// is purely name-based on the public `ShopifyGlobal` export, so any
// surface's target can opt in by shipping this shape — there is no
// allowlist or hard-coded target in the CLI.
const genericTarget = 'fake-surface.any-target.render'

const {extension} = await setupUIExtensionWithNodeModules({
tmpDir,
fileContent: '// JSX code',
apiVersion: '2025-10',
target: genericTarget,
targetDtsContent: `
interface _FakeShopifyGlobal { someHostApi(): void }
export type {_FakeShopifyGlobal as ShopifyGlobal}
export type Api = {placeholder: true}
`,
})

const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
await writeFile(tsconfigPath, '// TypeScript config')

await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)

const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')

expect(typeDefinitionsByFile).toStrictEqual(
new Map([
[
shopifyDtsPath,
new Set([
`//@ts-ignore\ndeclare module './src/index.jsx' {
const shopify: import('@shopify/ui-extensions/${genericTarget}').Api &
import('@shopify/ui-extensions/${genericTarget}').ShopifyGlobal;
const globalThis: { shopify: typeof shopify };
}\n`,
]),
],
]),
)
})
})

test('emits plain Api when target does not re-export ShopifyGlobal', async () => {
const typeDefinitionsByFile = new Map<string, Set<string>>()

await inTemporaryDirectory(async (tmpDir) => {
// No `targetDtsContent` — the helper writes the default placeholder,
// which contains no `ShopifyGlobal` export. This guards against the
// detection helper accidentally tripping on targets that don't opt in.
const {extension} = await setupUIExtensionWithNodeModules({
tmpDir,
fileContent: '// JSX code',
apiVersion: '2025-10',
})

const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
await writeFile(tsconfigPath, '// TypeScript config')

await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)

const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')

expect(typeDefinitionsByFile).toStrictEqual(
new Map([
[
shopifyDtsPath,
new Set([
`//@ts-ignore\ndeclare module './src/index.jsx' {
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;
const globalThis: { shopify: typeof shopify };
}\n`,
]),
],
]),
)
})
})

test("throws error when when api version supports Remote DOM and there's a tsconfig.json but type reference for target could not be found", async () => {
const typeDefinitionsByFile = new Map<string, Set<string>>()

Expand Down
Loading