Skip to content

Add new loading mode: local #6127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 22, 2025
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/tender-readers-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Fix issue in `app build` when the app has certain extension types
35 changes: 35 additions & 0 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,41 @@ wrong = "property"
await expect(loadTestingApp()).rejects.toThrow(/Invalid extension type "invalid_type"/)
})

test('loads only known extension types when mode is local', async () => {
// Given
await writeConfig(appConfiguration)

// Create two extensions: one known and one unknown
const knownBlockConfiguration = `
name = "my_extension"
type = "theme"
`
await writeBlockConfig({
blockConfiguration: knownBlockConfiguration,
name: 'my-known-extension',
})
await writeFile(joinPath(blockPath('my-known-extension'), 'index.js'), '')

const unknownBlockConfiguration = `
name = "unknown_extension"
type = "unknown_type"
`
await writeBlockConfig({
blockConfiguration: unknownBlockConfiguration,
name: 'my-unknown-extension',
})
await writeFile(joinPath(blockPath('my-unknown-extension'), 'index.js'), '')

// When
const app = await loadTestingApp({mode: 'local'})

// Then
expect(app.allExtensions).toHaveLength(1)
expect(app.allExtensions[0]!.configuration.name).toBe('my_extension')
expect(app.allExtensions[0]!.configuration.type).toBe('theme')
expect(app.errors).toBeUndefined()
})

test('throws if 2 or more extensions have the same handle', async () => {
// Given
await writeConfig(appConfiguration)
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ import ignore from 'ignore'

const defaultExtensionDirectory = 'extensions/*'

export type AppLoaderMode = 'strict' | 'report'
/**
* The mode in which the app is loaded, this affects how errors are handled:
* - strict: If there is any kind of error, the app won't be loaded.
* - report: The app will be loaded as much as possible, errors will be reported afterwards.
* - local: Errors for unknown extensions will be ignored. Other errors will prevent the app from loading.
*/
export type AppLoaderMode = 'strict' | 'report' | 'local'

type AbortOrReport = <T>(errorMessage: OutputMessage, fallback: T, configurationPath: string) => T

Expand Down Expand Up @@ -466,6 +472,8 @@ class AppLoader<TConfig extends AppConfiguration, TModuleSpec extends ExtensionS

if (specification) {
usedKnownSpecification = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not your change, but usedKnownSpecification seems pointless in this method since if there is no specification we always return early from the method, so there's no need to be checking usedKnownSpecification further down.

Copy link
Contributor Author

@isaacroldan isaacroldan Jul 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, that's still useful when not using the local mode. It makes sense for the report mode.

} else if (this.mode === 'local') {
return undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a good sense of how the contents of the loaded app (the extensions list) are used throughout the CLI? If we knew this was only used by build it would be safer, but this loader could be used anywhere - how can we guarantee that all usages (current and future) will be OK with skipping an unknown extension?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is tricky, for instance the only command that uses the report loader mode is the app info command, because we want to surface errors but still load the app.
But we can't prevent other commands from using that loading mode (unless we add a special eslint rule I guess). That's why we called it unsafeReportMode.

In this case, the local mode will only be used when using the localAppContext, which should only be used by unauthenticated commands (app build and app function).

} else {
return this.abortOrReport(
outputContent`Invalid extension type "${type}" in "${relativizePath(configurationPath)}"`,
Expand Down
50 changes: 0 additions & 50 deletions packages/app/src/cli/services/app-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,56 +358,6 @@ describe('localAppContext', () => {
})
})

test('uses unsafeReportMode when provided', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given - use a valid configuration but with an extra field to test report mode
const content = `
name = "test-app"
`
await writeAppConfig(tmp, content)
const loadSpy = vi.spyOn(loader, 'loadApp')

// When
const result = await localAppContext({
directory: tmp,
unsafeReportMode: true,
})

// Then
expect(result).toBeDefined()
expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'report',
}),
)
loadSpy.mockRestore()
})
})

test('defaults to strict mode when unsafeReportMode is not provided', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
const content = `
name = "test-app"
`
await writeAppConfig(tmp, content)
const loadSpy = vi.spyOn(loader, 'loadApp')

// When
await localAppContext({
directory: tmp,
})

// Then
expect(loadSpy).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'strict',
}),
)
loadSpy.mockRestore()
})
})

test('loads app with extensions', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
Expand Down
3 changes: 1 addition & 2 deletions packages/app/src/cli/services/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ async function logMetadata(app: {apiKey: string}, organization: Organization, re
export async function localAppContext({
directory,
userProvidedConfigName,
unsafeReportMode = false,
}: LocalAppContextOptions): Promise<AppInterface> {
// Load local specifications only
const specifications = await loadLocalExtensionsSpecifications()
Expand All @@ -162,6 +161,6 @@ export async function localAppContext({
directory,
userProvidedConfigName,
specifications,
mode: unsafeReportMode ? 'report' : 'strict',
mode: 'local',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think since we dont' know who is using localAppContext, it's dangerous to assume we always want local here. I think we should pass an argument into localAppContext (similar to how it was before) so that the command can determine whether it's safe to skip unknown extensions when loading the local app. Since there might be cases where a default value of local is wrong and then bad things happen

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Umm, my assumption was that if this is a local app, the expectation is that only local specifications can be used, and we should never crash in that case.

Since there might be cases where a default value of local is wrong and then bad things happen

I can't imagine a case where you would want to load the app with the local specs, but crash if there is an unknown extension 🤔. Right now the default mode is strict and that's what's causing the issues that made us make this change

The way I see it:
localAppContext -> local mode
linkedAppContext -> strict or report modes

})
}