Skip to content

Commit 3598271

Browse files
authored
Merge pull request #7387 from Shopify/vchu/shopify-global-type-detection
Detect ShopifyGlobal re-export, emit intersection in shopify.d.ts
2 parents 397d638 + 3911ddb commit 3598271

3 files changed

Lines changed: 212 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: 135 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,134 @@ 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+
// The target re-exports `ShopifyGlobal` via a named export specifier,
1386+
// which is the shape the AST helper detects. Any surface can opt in
1387+
// by emitting this shape from its target `.d.ts`.
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('ShopifyGlobal detection is target-agnostic — any target with the re-export opts in', async () => {
1423+
const typeDefinitionsByFile = new Map<string, Set<string>>()
1424+
1425+
await inTemporaryDirectory(async (tmpDir) => {
1426+
// A fabricated target name belonging to no real surface. The detector
1427+
// is purely name-based on the public `ShopifyGlobal` export, so any
1428+
// surface's target can opt in by shipping this shape — there is no
1429+
// allowlist or hard-coded target in the CLI.
1430+
const genericTarget = 'fake-surface.any-target.render'
1431+
1432+
const {extension} = await setupUIExtensionWithNodeModules({
1433+
tmpDir,
1434+
fileContent: '// JSX code',
1435+
apiVersion: '2025-10',
1436+
target: genericTarget,
1437+
targetDtsContent: `
1438+
interface _FakeShopifyGlobal { someHostApi(): void }
1439+
export type {_FakeShopifyGlobal as ShopifyGlobal}
1440+
export type Api = {placeholder: true}
1441+
`,
1442+
})
1443+
1444+
const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
1445+
await writeFile(tsconfigPath, '// TypeScript config')
1446+
1447+
await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)
1448+
1449+
const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')
1450+
1451+
expect(typeDefinitionsByFile).toStrictEqual(
1452+
new Map([
1453+
[
1454+
shopifyDtsPath,
1455+
new Set([
1456+
`//@ts-ignore\ndeclare module './src/index.jsx' {
1457+
const shopify: import('@shopify/ui-extensions/${genericTarget}').Api &
1458+
import('@shopify/ui-extensions/${genericTarget}').ShopifyGlobal;
1459+
const globalThis: { shopify: typeof shopify };
1460+
}\n`,
1461+
]),
1462+
],
1463+
]),
1464+
)
1465+
})
1466+
})
1467+
1468+
test('emits plain Api when target does not re-export ShopifyGlobal', async () => {
1469+
const typeDefinitionsByFile = new Map<string, Set<string>>()
1470+
1471+
await inTemporaryDirectory(async (tmpDir) => {
1472+
// No `targetDtsContent` — the helper writes the default placeholder,
1473+
// which contains no `ShopifyGlobal` export. This guards against the
1474+
// detection helper accidentally tripping on targets that don't opt in.
1475+
const {extension} = await setupUIExtensionWithNodeModules({
1476+
tmpDir,
1477+
fileContent: '// JSX code',
1478+
apiVersion: '2025-10',
1479+
})
1480+
1481+
const tsconfigPath = joinPath(tmpDir, 'tsconfig.json')
1482+
await writeFile(tsconfigPath, '// TypeScript config')
1483+
1484+
await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile)
1485+
1486+
const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts')
1487+
1488+
expect(typeDefinitionsByFile).toStrictEqual(
1489+
new Map([
1490+
[
1491+
shopifyDtsPath,
1492+
new Set([
1493+
`//@ts-ignore\ndeclare module './src/index.jsx' {
1494+
const shopify: import('@shopify/ui-extensions/admin.product-details.action.render').Api;
1495+
const globalThis: { shopify: typeof shopify };
1496+
}\n`,
1497+
]),
1498+
],
1499+
]),
1500+
)
1501+
})
1502+
})
1503+
13701504
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 () => {
13711505
const typeDefinitionsByFile = new Map<string, Set<string>>()
13721506

0 commit comments

Comments
 (0)