From c70e53630608ffaefd544a563969c05cefb4dc1b Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 15:48:54 -0700 Subject: [PATCH 1/2] 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) --- .changeset/shopify-global-type-detection.md | 5 ++ .../specifications/type-generation.ts | 88 ++++++++++++++---- .../specifications/ui_extension.test.ts | 90 ++++++++++++++++++- 3 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 .changeset/shopify-global-type-detection.md diff --git a/.changeset/shopify-global-type-detection.md b/.changeset/shopify-global-type-detection.md new file mode 100644 index 00000000000..72eec3a0cde --- /dev/null +++ b/.changeset/shopify-global-type-detection.md @@ -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. diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index 2dc8b70743e..6be61749f49 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -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('').Api & import('').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, + 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({ @@ -193,13 +245,18 @@ export function createTypeDefinition({ toolsTypeDefinition, }: CreateTypeDefinitionOptions): string | null { try { - // Validate that all targets can be resolved + const resolvedTargetPaths = new Map() + + // 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) { 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.`, @@ -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 = [ @@ -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 } 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 7e27bcece32..5259b6ebbd1 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 @@ -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') @@ -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/')` 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') let shouldRenderFilePath if (shouldRenderFileContent) { @@ -1367,6 +1373,88 @@ Please check the configuration in ${uiExtension.configurationPath}`), }) }) + test('emits Api & ShopifyGlobal intersection when target re-exports ShopifyGlobal', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// JSX code', + // Remote DOM supported version + apiVersion: '2025-10', + // Mirrors the POS ui-extensions pattern: the target re-exports + // `ShopifyGlobal` via a named export specifier, which is the shape + // the AST helper detects. + 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('emits plain Api when target does not re-export ShopifyGlobal', async () => { + const typeDefinitionsByFile = new Map>() + + 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>() From 3911ddb3d0ef979d234bd4c50a407afc1cddd6f7 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 23 Apr 2026 17:33:54 -0700 Subject: [PATCH 2/2] test: assert ShopifyGlobal detection is target-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third test case that uses a fabricated target name belonging to no real surface. Documents in the test body that the detector has no allowlist and no hard-coded target — any surface can opt in by shipping the same export shape from its target `.d.ts`. Softens the POS-specific phrasing in the first test's comment; POS is the first adopter, not a special case. --- .../specifications/ui_extension.test.ts | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) 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 5259b6ebbd1..be8441e3d40 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 @@ -1382,9 +1382,9 @@ Please check the configuration in ${uiExtension.configurationPath}`), fileContent: '// JSX code', // Remote DOM supported version apiVersion: '2025-10', - // Mirrors the POS ui-extensions pattern: the target re-exports - // `ShopifyGlobal` via a named export specifier, which is the shape - // the AST helper detects. + // 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} @@ -1419,6 +1419,52 @@ Please check the configuration in ${uiExtension.configurationPath}`), }) }) + test('ShopifyGlobal detection is target-agnostic — any target with the re-export opts in', async () => { + const typeDefinitionsByFile = new Map>() + + 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>()