Skip to content

Commit c70e536

Browse files
vctrchuclaude
andcommitted
Emit intersection type in shopify.d.ts when target re-exports ShopifyGlobal
The CLI-generated shopify.d.ts now types the `shopify` binding as `Api & ShopifyGlobal` for UI extension targets whose .d.ts re-exports a type named `ShopifyGlobal`. Detection is AST-based via the typescript compiler API (already a dependency), matching on the public export name `ShopifyGlobal` so the CLI does not need to know about specific surfaces or targets. Targets that do not re-export `ShopifyGlobal` emit byte-identical output to main. Existing consumers who access the target API via `shopify.*` are unaffected. Net effect: host-level APIs like `shopify.addEventListener` now type-check automatically for opt-in targets (e.g. POS background extensions) without any CLI release coordination when new targets opt in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a61a9f2 commit c70e536

3 files changed

Lines changed: 166 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
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.

packages/app/src/cli/models/extensions/specifications/type-generation.ts

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -166,23 +166,75 @@ interface CreateTypeDefinitionOptions {
166166
}
167167

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

175-
if (targets.length === 1) {
176-
const target = targets[0] ?? ''
177-
return `import('@shopify/ui-extensions/${target}').Api${toolsSuffix}`
185+
const sourceFile = ts.createSourceFile(targetDtsPath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
186+
187+
let found = false
188+
const visit = (node: ts.Node): void => {
189+
if (found) return
190+
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
191+
for (const specifier of node.exportClause.elements) {
192+
// Match on the exported (public) name. For `export {ShopifyGlobal}`,
193+
// that's specifier.name. For `export {Foo as ShopifyGlobal}`,
194+
// specifier.name is still 'ShopifyGlobal' (the public alias); the
195+
// internal/local name 'Foo' lives on specifier.propertyName.
196+
if (specifier.name.text === 'ShopifyGlobal') {
197+
found = true
198+
return
199+
}
200+
}
201+
}
202+
ts.forEachChild(node, visit)
178203
}
204+
visit(sourceFile)
205+
return found
206+
}
179207

180-
if (targets.length > 1) {
181-
const unionType = targets.map((target) => `import('@shopify/ui-extensions/${target}').Api`).join(' | ')
182-
return `(${unionType})${toolsSuffix}`
208+
/**
209+
* Builds the shopify API type based on targets, their resolved .d.ts paths,
210+
* and optional tools type.
211+
*
212+
* If a target re-exports `ShopifyGlobal`, the emitted type is
213+
* `import('<target>').Api & import('<target>').ShopifyGlobal` so consumers
214+
* retain access to both the target's data surface and host-level APIs
215+
* (e.g. `shopify.addEventListener`). Otherwise emits just `.Api`.
216+
*
217+
* Returns null if no targets are provided.
218+
*/
219+
function buildShopifyType(
220+
targets: string[],
221+
resolvedTargetPaths: Map<string, string>,
222+
toolsTypeDefinition?: string,
223+
): string | null {
224+
const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : ''
225+
226+
const typeForTarget = (target: string): string => {
227+
const base = `import('@shopify/ui-extensions/${target}').Api`
228+
const dtsPath = resolvedTargetPaths.get(target)
229+
if (dtsPath && targetExportsShopifyGlobal(dtsPath)) {
230+
return `${base} & import('@shopify/ui-extensions/${target}').ShopifyGlobal`
231+
}
232+
return base
183233
}
184234

185-
return null
235+
if (targets.length === 0) return null
236+
if (targets.length === 1) return `${typeForTarget(targets[0] ?? '')}${toolsSuffix}`
237+
return `(${targets.map(typeForTarget).join(' | ')})${toolsSuffix}`
186238
}
187239

188240
export function createTypeDefinition({
@@ -193,13 +245,18 @@ export function createTypeDefinition({
193245
toolsTypeDefinition,
194246
}: CreateTypeDefinitionOptions): string | null {
195247
try {
196-
// Validate that all targets can be resolved
248+
const resolvedTargetPaths = new Map<string, string>()
249+
250+
// Validate that all targets can be resolved, and capture the resolved .d.ts
251+
// path so buildShopifyType can inspect it for ShopifyGlobal exports.
197252
for (const target of targets) {
198253
try {
199-
require.resolve(`@shopify/ui-extensions/${target}`, {paths: [fullPath, typeFilePath]})
254+
const resolved = require.resolve(`@shopify/ui-extensions/${target}`, {
255+
paths: [fullPath, typeFilePath],
256+
})
257+
resolvedTargetPaths.set(target, resolved)
200258
} catch (_) {
201259
const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10}
202-
// Throw specific error for the target that failed, matching the original getSharedTypeDefinition behavior
203260
throw new AbortError(
204261
`Type reference for ${target} could not be found. You might be using the wrong @shopify/ui-extensions version.`,
205262
`Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`,
@@ -209,7 +266,7 @@ export function createTypeDefinition({
209266

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

212-
const shopifyType = buildShopifyType(targets, toolsTypeDefinition)
269+
const shopifyType = buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition)
213270
if (!shopifyType) return null
214271

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

225282
return lines.join('\n')
226283
} catch (error) {
227-
// Re-throw AbortError as-is, wrap other errors
228284
if (error instanceof AbortError) {
229285
throw error
230286
}

packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1177,12 +1177,14 @@ Please check the configuration in ${uiExtension.configurationPath}`),
11771177
shouldRenderFileContent,
11781178
apiVersion,
11791179
target = 'admin.product-details.action.render',
1180+
targetDtsContent,
11801181
}: {
11811182
tmpDir: string
11821183
fileContent: string
11831184
shouldRenderFileContent?: string
11841185
apiVersion: string
11851186
target?: string
1187+
targetDtsContent?: string
11861188
}) {
11871189
// Create extension files
11881190
const srcDir = joinPath(tmpDir, 'src')
@@ -1197,7 +1199,11 @@ Please check the configuration in ${uiExtension.configurationPath}`),
11971199

11981200
const targetPath = joinPath(nodeModulesPath, target)
11991201
await mkdir(targetPath)
1200-
await writeFile(joinPath(targetPath, 'index.js'), '// Mock UI extension target')
1202+
// `require.resolve('@shopify/ui-extensions/<target>')` resolves to this file,
1203+
// and the CLI's ShopifyGlobal detector reads whatever path require.resolve
1204+
// returned. Injecting `targetDtsContent` here lets tests exercise the
1205+
// detection branch; defaults preserve the original placeholder.
1206+
await writeFile(joinPath(targetPath, 'index.js'), targetDtsContent ?? '// Mock UI extension target')
12011207

12021208
let shouldRenderFilePath
12031209
if (shouldRenderFileContent) {
@@ -1367,6 +1373,88 @@ Please check the configuration in ${uiExtension.configurationPath}`),
13671373
})
13681374
})
13691375

1376+
test('emits Api & ShopifyGlobal intersection when target re-exports ShopifyGlobal', async () => {
1377+
const typeDefinitionsByFile = new Map<string, Set<string>>()
1378+
1379+
await inTemporaryDirectory(async (tmpDir) => {
1380+
const {extension} = await setupUIExtensionWithNodeModules({
1381+
tmpDir,
1382+
fileContent: '// JSX code',
1383+
// Remote DOM supported version
1384+
apiVersion: '2025-10',
1385+
// Mirrors the POS ui-extensions pattern: the target re-exports
1386+
// `ShopifyGlobal` via a named export specifier, which is the shape
1387+
// the AST helper detects.
1388+
targetDtsContent: `
1389+
interface _ShopifyGlobalInternal { addEventListener(type: string, listener: (event: unknown) => void): void }
1390+
export type {_ShopifyGlobalInternal as ShopifyGlobal}
1391+
export type Api = {placeholder: true}
1392+
`,
1393+
})
1394+
1395+
// Create tsconfig.json
1396+
const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
1397+
await writeFile(tsconfigPath, '// TypeScript config')
1398+
1399+
// When
1400+
await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)
1401+
1402+
const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')
1403+
1404+
// Then — prettier wraps the long intersection onto two lines.
1405+
expect(typeDefinitionsByFile).toStrictEqual(
1406+
new Map([
1407+
[
1408+
shopifyDtsPath,
1409+
new Set([
1410+
`//@ts-ignore\ndeclare module './src/index.jsx' {
1411+
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api &
1412+
import('@shopify/ui-extensions/admin.product-details.action.render').ShopifyGlobal;
1413+
const globalThis: { shopify: typeof shopify };
1414+
}\n`,
1415+
]),
1416+
],
1417+
]),
1418+
)
1419+
})
1420+
})
1421+
1422+
test('emits plain Api when target does not re-export ShopifyGlobal', async () => {
1423+
const typeDefinitionsByFile = new Map<string, Set<string>>()
1424+
1425+
await inTemporaryDirectory(async (tmpDir) => {
1426+
// No `targetDtsContent` — the helper writes the default placeholder,
1427+
// which contains no `ShopifyGlobal` export. This guards against the
1428+
// detection helper accidentally tripping on targets that don't opt in.
1429+
const {extension} = await setupUIExtensionWithNodeModules({
1430+
tmpDir,
1431+
fileContent: '// JSX code',
1432+
apiVersion: '2025-10',
1433+
})
1434+
1435+
const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
1436+
await writeFile(tsconfigPath, '// TypeScript config')
1437+
1438+
await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)
1439+
1440+
const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')
1441+
1442+
expect(typeDefinitionsByFile).toStrictEqual(
1443+
new Map([
1444+
[
1445+
shopifyDtsPath,
1446+
new Set([
1447+
`//@ts-ignore\ndeclare module './src/index.jsx' {
1448+
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;
1449+
const globalThis: { shopify: typeof shopify };
1450+
}\n`,
1451+
]),
1452+
],
1453+
]),
1454+
)
1455+
})
1456+
})
1457+
13701458
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 () => {
13711459
const typeDefinitionsByFile = new Map<string, Set<string>>()
13721460

0 commit comments

Comments
 (0)