Skip to content

Commit abff4e9

Browse files
authored
Merge pull request #6605 from Shopify/fix-theme-dev-failing-analytics
Fix theme dev failing analytics
2 parents 1421d07 + 9c35fb1 commit abff4e9

File tree

7 files changed

+127
-49
lines changed

7 files changed

+127
-49
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
interface PromiseWithResolvers<T> {
2+
promise: Promise<T>
3+
resolve: (value: T | PromiseLike<T>) => void
4+
reject: (reason?: unknown) => void
5+
}
6+
7+
// Polyfill for Promise.withResolvers
8+
// Can remove once our minimum supported Node version is 22
9+
export function promiseWithResolvers<T>(): PromiseWithResolvers<T> {
10+
if (typeof Promise.withResolvers === 'function') {
11+
return Promise.withResolvers<T>()
12+
}
13+
14+
let resolve!: (value: T | PromiseLike<T>) => void
15+
let reject!: (reason?: unknown) => void
16+
const promise = new Promise<T>((_resolve, _reject) => {
17+
resolve = _resolve
18+
reject = _reject
19+
})
20+
21+
return {promise, resolve, reject}
22+
}

packages/theme/src/cli/services/dev.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,23 +119,7 @@ export async function dev(options: DevOptions) {
119119
},
120120
}
121121

122-
if (options['theme-editor-sync']) {
123-
session.storefrontPassword = await storefrontPasswordPromise
124-
}
125-
126-
const {serverStart, renderDevSetupProgress} = setupDevServer(options.theme, ctx)
127-
128-
if (!options['theme-editor-sync']) {
129-
session.storefrontPassword = await storefrontPasswordPromise
130-
}
131-
132-
await renderDevSetupProgress()
133-
await serverStart()
134-
135-
renderLinks(urls)
136-
if (options.open) {
137-
openURLSafely(urls.local, 'development server')
138-
}
122+
const {serverStart, renderDevSetupProgress, backgroundJobPromise} = setupDevServer(options.theme, ctx)
139123

140124
readline.emitKeypressEvents(process.stdin)
141125
if (process.stdin.isTTY) {
@@ -167,6 +151,18 @@ export async function dev(options: DevOptions) {
167151
break
168152
}
169153
})
154+
155+
await Promise.all([
156+
backgroundJobPromise,
157+
renderDevSetupProgress()
158+
.then(serverStart)
159+
.then(() => {
160+
renderLinks(urls)
161+
if (options.open) {
162+
openURLSafely(urls.local, 'development server')
163+
}
164+
}),
165+
])
170166
}
171167

172168
export function openURLSafely(url: string, label: string) {

packages/theme/src/cli/utilities/theme-environment/remote-theme-watcher.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('reconcileAndPollThemeEditorChanges', async () => {
2828
const files = new Map<string, ThemeAsset>([])
2929
const defaultThemeFileSystem = fakeThemeFileSystem('tmp', files)
3030
const initialRemoteChecksums = [{checksum: '1', key: 'templates/asset.json'}]
31+
const mockRejectBackgroundJob = vi.fn()
3132

3233
vi.mocked(fetchChecksums).mockResolvedValue([{checksum: '2', key: 'templates/asset.json'}])
3334

@@ -42,6 +43,7 @@ describe('reconcileAndPollThemeEditorChanges', async () => {
4243
ignore: [],
4344
only: [],
4445
},
46+
mockRejectBackgroundJob,
4547
)
4648
await workPromise
4749
await updatedRemoteChecksumsPromise
@@ -57,6 +59,7 @@ describe('reconcileAndPollThemeEditorChanges', async () => {
5759
ignore: [],
5860
only: [],
5961
},
62+
mockRejectBackgroundJob,
6063
)
6164
})
6265

@@ -65,6 +68,7 @@ describe('reconcileAndPollThemeEditorChanges', async () => {
6568
const files = new Map<string, ThemeAsset>([])
6669
const defaultThemeFileSystem = fakeThemeFileSystem('tmp', files)
6770
const initialRemoteChecksums = [{checksum: '1', key: 'templates/asset.json'}]
71+
const mockRejectBackgroundJob = vi.fn()
6872
const options = {
6973
noDelete: false,
7074
ignore: [],
@@ -83,6 +87,7 @@ describe('reconcileAndPollThemeEditorChanges', async () => {
8387
initialRemoteChecksums,
8488
defaultThemeFileSystem,
8589
options,
90+
mockRejectBackgroundJob,
8691
)
8792

8893
// Then

packages/theme/src/cli/utilities/theme-environment/remote-theme-watcher.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export async function reconcileAndPollThemeEditorChanges(
2222
ignore: string[]
2323
only: string[]
2424
},
25+
rejectBackgroundJob: (reason?: unknown) => void,
2526
): Promise<{
2627
updatedRemoteChecksumsPromise: Promise<Checksum[]>
2728
workPromise: Promise<void>
@@ -33,7 +34,14 @@ export async function reconcileAndPollThemeEditorChanges(
3334

3435
const updatedRemoteChecksumsPromise = workPromise.then(async () => {
3536
const updatedRemoteChecksums = await fetchChecksums(targetTheme.id, session)
36-
pollThemeEditorChanges(targetTheme, session, updatedRemoteChecksums, localThemeFileSystem, options)
37+
pollThemeEditorChanges(
38+
targetTheme,
39+
session,
40+
updatedRemoteChecksums,
41+
localThemeFileSystem,
42+
options,
43+
rejectBackgroundJob,
44+
)
3745
return updatedRemoteChecksums
3846
})
3947

packages/theme/src/cli/utilities/theme-environment/theme-environment.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest'
1111
import {buildTheme} from '@shopify/cli-kit/node/themes/factories'
1212
import {createEvent} from 'h3'
1313
import * as output from '@shopify/cli-kit/node/output'
14+
import {fetchChecksums} from '@shopify/cli-kit/node/themes/api'
1415
import {IncomingMessage, ServerResponse} from 'node:http'
1516
import {Socket} from 'node:net'
1617

17-
vi.mock('@shopify/cli-kit/node/themes/api', () => ({fetchChecksums: () => Promise.resolve([])}))
18+
vi.mock('@shopify/cli-kit/node/themes/api', () => ({fetchChecksums: vi.fn(() => Promise.resolve([]))}))
1819
vi.mock('./remote-theme-watcher.js')
1920
vi.mock('./storefront-renderer.js')
2021
vi.spyOn(output, 'outputDebug')
@@ -158,7 +159,15 @@ describe('setupDevServer', () => {
158159
[],
159160
context.localThemeFileSystem,
160161
{noDelete: true, ...filters},
162+
expect.anything(),
161163
)
164+
// This is the best way I could think of verifying the rejectBackgroundJob
165+
// Verify the rejectBackgroundJob callback is a function accepting one argument
166+
const callArgs = vi.mocked(reconcileAndPollThemeEditorChanges).mock.calls[0]
167+
const rejectCallback = callArgs?.[5]
168+
expect(rejectCallback).toBeTypeOf('function')
169+
// Reject callbacks take 1 argument: the rejection reason
170+
expect(rejectCallback).toHaveLength(1)
162171
})
163172

164173
test('should skip deletion of remote files if noDelete flag is passed', async () => {
@@ -179,6 +188,22 @@ describe('setupDevServer', () => {
179188
})
180189
})
181190

191+
test('should catch errors from fetchChecksums and reject backgroundJobPromise', async () => {
192+
// Given
193+
const context: DevServerContext = {
194+
...defaultServerContext,
195+
}
196+
const expectedError = new Error('Failed to fetch checksums from API')
197+
198+
vi.mocked(fetchChecksums).mockRejectedValueOnce(expectedError)
199+
200+
// When
201+
const {backgroundJobPromise} = setupDevServer(developmentTheme, context)
202+
203+
// Then
204+
await expect(backgroundJobPromise).rejects.toThrow('Failed to fetch checksums from API')
205+
})
206+
182207
describe('request handling', async () => {
183208
const context = {...defaultServerContext}
184209
const server = setupDevServer(developmentTheme, context)

packages/theme/src/cli/utilities/theme-environment/theme-environment.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import {getProxyHandler} from './proxy.js'
55
import {reconcileAndPollThemeEditorChanges} from './remote-theme-watcher.js'
66
import {uploadTheme} from '../theme-uploader.js'
77
import {renderTasksToStdErr} from '../theme-ui.js'
8-
import {createAbortCatchError} from '../errors.js'
8+
import {renderThrownError} from '../errors.js'
9+
import {promiseWithResolvers} from '../../polyfills/promiseWithResolvers.js'
910
import {createApp, defineEventHandler, defineLazyEventHandler, toNodeListener, handleCors} from 'h3'
1011
import {fetchChecksums} from '@shopify/cli-kit/node/themes/api'
1112
import {createServer} from 'node:http'
1213
import type {Checksum, Theme} from '@shopify/cli-kit/node/themes/types'
1314
import type {DevServerContext} from './types.js'
1415

1516
export function setupDevServer(theme: Theme, ctx: DevServerContext) {
17+
const {promise: backgroundJobPromise, reject: rejectBackgroundJob} = promiseWithResolvers<never>()
18+
1619
const watcherPromise = setupInMemoryTemplateWatcher(theme, ctx)
17-
const envSetup = ensureThemeEnvironmentSetup(theme, ctx)
20+
const envSetup = ensureThemeEnvironmentSetup(theme, ctx, rejectBackgroundJob)
1821
const workPromise = Promise.all([watcherPromise, envSetup.workPromise]).then(() =>
1922
ctx.localThemeFileSystem.startWatcher(theme.id.toString(), ctx.session),
2023
)
@@ -25,31 +28,43 @@ export function setupDevServer(theme: Theme, ctx: DevServerContext) {
2528
serverStart: server.start,
2629
dispatchEvent: server.dispatch,
2730
renderDevSetupProgress: envSetup.renderProgress,
31+
backgroundJobPromise,
2832
}
2933
}
3034

31-
function ensureThemeEnvironmentSetup(theme: Theme, ctx: DevServerContext) {
32-
const abort = createAbortCatchError('Failed to perform the initial theme synchronization.')
35+
function ensureThemeEnvironmentSetup(
36+
theme: Theme,
37+
ctx: DevServerContext,
38+
rejectBackgroundJob: (reason?: unknown) => void,
39+
) {
40+
const abort = (error: Error): never => {
41+
renderThrownError('Failed to perform the initial theme synchronization.', error)
42+
rejectBackgroundJob(error)
43+
// Return a never-resolving promise to stop this promise chain without throwing.
44+
// Throwing would trigger catch handlers and continue execution. This stops the
45+
// chain while the error is handled through the separate backgroundJobPromise channel.
46+
return new Promise<never>(() => {}) as never
47+
}
3348

34-
const remoteChecksumsPromise = fetchChecksums(theme.id, ctx.session).catch(abort)
49+
const remoteChecksumsPromise = fetchChecksums(theme.id, ctx.session)
3550

36-
const reconcilePromise = remoteChecksumsPromise
37-
.then((remoteChecksums) => handleThemeEditorSync(theme, ctx, remoteChecksums))
38-
.catch(abort)
51+
const reconcilePromise = remoteChecksumsPromise.then((remoteChecksums) =>
52+
handleThemeEditorSync(theme, ctx, remoteChecksums, rejectBackgroundJob),
53+
)
3954

40-
const uploadPromise = reconcilePromise
41-
.then(async ({updatedRemoteChecksumsPromise}) => {
42-
const updatedRemoteChecksums = await updatedRemoteChecksumsPromise
43-
return uploadTheme(theme, ctx.session, updatedRemoteChecksums, ctx.localThemeFileSystem, {
44-
nodelete: ctx.options.noDelete,
45-
deferPartialWork: true,
46-
backgroundWorkCatch: abort,
47-
})
55+
const uploadPromise = reconcilePromise.then(async ({updatedRemoteChecksumsPromise}) => {
56+
const updatedRemoteChecksums = await updatedRemoteChecksumsPromise
57+
return uploadTheme(theme, ctx.session, updatedRemoteChecksums, ctx.localThemeFileSystem, {
58+
nodelete: ctx.options.noDelete,
59+
deferPartialWork: true,
60+
backgroundWorkCatch: abort,
4861
})
49-
.catch(abort)
62+
})
63+
64+
const workPromise = uploadPromise.then((result) => result.workPromise).catch(abort)
5065

5166
return {
52-
workPromise: uploadPromise.then((result) => result.workPromise).catch(abort),
67+
workPromise,
5368
renderProgress: async () => {
5469
if (ctx.options.themeEditorSync) {
5570
const {workPromise} = await reconcilePromise
@@ -74,16 +89,24 @@ function handleThemeEditorSync(
7489
theme: Theme,
7590
ctx: DevServerContext,
7691
remoteChecksums: Checksum[],
92+
rejectBackgroundJob: (reason?: unknown) => void,
7793
): Promise<{
7894
updatedRemoteChecksumsPromise: Promise<Checksum[]>
7995
workPromise: Promise<void>
8096
}> {
8197
if (ctx.options.themeEditorSync) {
82-
return reconcileAndPollThemeEditorChanges(theme, ctx.session, remoteChecksums, ctx.localThemeFileSystem, {
83-
noDelete: ctx.options.noDelete,
84-
ignore: ctx.options.ignore,
85-
only: ctx.options.only,
86-
})
98+
return reconcileAndPollThemeEditorChanges(
99+
theme,
100+
ctx.session,
101+
remoteChecksums,
102+
ctx.localThemeFileSystem,
103+
{
104+
noDelete: ctx.options.noDelete,
105+
ignore: ctx.options.ignore,
106+
only: ctx.options.only,
107+
},
108+
rejectBackgroundJob,
109+
)
87110
} else {
88111
return Promise.resolve({
89112
updatedRemoteChecksumsPromise: Promise.resolve(remoteChecksums),

packages/theme/src/cli/utilities/theme-environment/theme-polling.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function pollThemeEditorChanges(
2121
remoteChecksum: Checksum[],
2222
localFileSystem: ThemeFileSystem,
2323
options: PollingOptions,
24+
rejectBackgroundJob: (reason?: unknown) => void,
2425
) {
2526
outputDebug('Listening for changes in the theme editor')
2627

@@ -51,14 +52,12 @@ export function pollThemeEditorChanges(
5152
}
5253

5354
if (failedPollingAttempts >= maxPollingAttempts) {
54-
renderFatalError(
55-
new AbortError(
56-
'Too many polling errors...',
57-
'Please check the errors above and ensure you have a stable internet connection.',
58-
),
55+
const fatalError = new AbortError(
56+
'Too many polling errors...',
57+
'Please check the errors above and ensure you have a stable internet connection.',
5958
)
60-
61-
process.exit(1)
59+
renderFatalError(fatalError)
60+
rejectBackgroundJob(fatalError)
6261
}
6362

6463
return latestChecksums

0 commit comments

Comments
 (0)