Fix theme dev failing analytics
WHY are these changes introduced?
Closes: https://github.com/Shopify/developer-tools-team/issues/902
Analytics aren't logging when we have a theme dev error during bootup or a background promise rejection during a session. This is because we call process.exit(1) (process.kill), which ends the program immediately before the finally block in theme-command.ts can fire off the analytics event.
WHAT is this pull request doing?
Implementing a deferred promise pattern using Promise.withResolvers(). This is new to Node 22, but I've polyfilled it since we still support Node 18+ as a minimum version.
Promise.withResolvers allows us to pass around resolve/reject around and have it called outside of the original promise. This lets us create a background promise that will only be called when we have an error.
How it works:
Promise.withResolvers() returns an object with three properties:
- promise - a standard promise
- resolve - a function to resolve that promise
- reject - a function to reject that promise
These resolve/reject functions can be passed around and called from anywhere in the code, not just inside the promise. This lets us create a "control promise" that waits indefinitely unless we explicitly reject it.
The flow:
- User runs
theme dev dev.tscallssetupDevServer()intheme-environment.ts- In
setupDevServer(): We create a deferred promise that never resolves, only rejects on fatal errors:const {promise: backgroundJobPromise, reject: rejectBackgroundJob} = promiseWithResolvers<never>()
- We pass the
rejectBackgroundJobfunction down through multiple layers:setupDevServer() → ensureThemeEnvironmentSetup() → handleThemeEditorSync() → reconcileAndPollThemeEditorChanges() → pollThemeEditorChanges()
- Each layer uses this reject function instead of calling
process.exit(1)when fatal errors occur - Back in dev.ts: We await both the server setup AND the background promise:
await Promise.all([
backgroundJobPromise // Hangs forever unless reject() is called
renderDevSetupProgress().then(serverStart).then(...)
])
- Since the background promise never resolves,
Promise.all()waits forever keeping the function alive. If an error occurs,rejectBackgroundJob(error)is called, which:
- Rejects the backgroundJobPromise
- Causes Promise.all() to reject
- Bubbles the error up to theme-command.ts
- Triggers the finally block → analytics get logged
- Process exits gracefully
How to test your changes?
In theme-command.ts add some logging to the finally block for analytics
finally {
console.log('🔍 Analytics logging...')
await this.logAnalyticsData(session)
console.log('✅ Analytics logged!')
}
In theme-environment.ts replace the const remoteCheckSumsPromise with
const remoteChecksumsPromise = fetchChecksums(theme.id, ctx.session)
.then(() => { throw new Error('TEST: Force failure') })
Run theme dev
Post-release steps
Measuring impact
How do we know this change was effective? Please choose one:
- [ ] n/a - this doesn't need measurement, e.g. a linting rule or a bug-fix
- [ ] Existing analytics will cater for this addition
- [ ] PR includes analytics changes to measure impact
Checklist
- [ ] I've considered possible cross-platform impacts (Mac, Linux, Windows)
- [ ] I've considered possible documentation changes
Coverage report
St.:grey_question: |
Category | Percentage | Covered / Total |
|---|---|---|---|
| 🟡 | Statements | 79.24% (+0.01% 🔼) |
13589/17150 |
| 🟡 | Branches | 73.13% (+0.02% 🔼) |
6630/9066 |
| 🟡 | Functions | 79.35% (-0.02% 🔻) |
3508/4421 |
| 🟡 | Lines | 79.59% (+0.01% 🔼) |
12833/16124 |
Show files with reduced coverage 🔻
St.:grey_question: |
File | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| 🟢 | ... / loader.ts |
93.86% | 86.7% (-0.13% 🔻) |
97.06% | 94.67% |
| 🟢 | ... / ConcurrentOutput.tsx |
98.36% (-1.64% 🔻) |
92% (-4% 🔻) |
100% | 98.33% (-1.67% 🔻) |
| 🔴 | ... / dev.ts |
15% (+1.67% 🔼) |
3.33% (+0.39% 🔼) |
50% (-7.14% 🔻) |
15% (+1.67% 🔼) |
| 🟡 | ... / theme-environment.ts |
69.81% (-1.62% 🔻) |
50% | 61.9% (+3.08% 🔼) |
69.23% (-2.2% 🔻) |
| 🟡 | ... / theme-polling.ts |
67.12% (-0.93% 🔻) |
68.75% | 78.57% | 66.67% (-0.98% 🔻) |
Test suite run success
3356 tests passing in 1372 suites.
Report generated by 🧪jest coverage report action from 781c67859883486584827c5ed901d884c29f83e7
/snapit
🫰✨ Thanks @EvilGenius13! Your snapshot has been published to npm.
Test the snapshot by installing your package globally:
npm i -g --@shopify:registry=https://registry.npmjs.org @shopify/[email protected]
[!CAUTION] After installing, validate the version by running just
shopifyin your terminal. If the versions don't match, you might have multiple global instances installed. Usewhich shopifyto find out which one you are running and uninstall it.
Good job! This was precisely the suggestion 💯
One more thing for you to think about it: if we don't like passing the reject callback down multiple levels, an alternative for this case would be relying on AsyncLocalStorage.
Basically, you'd pass the reject callback in the context as something like
const {promise, reject} = Promise.withResolvers(); const {serverStart, renderDevSetupProcess} = await asyncStorage.run({exitProcess: reject}, () => setupDevServer(...)) await Promise.all([promise, serverStart().then(...)])And anywhere in the logic we could run
asyncLocalStorage.getStore().exitProcess(error).In fact, maybe this is something that should go levels above in the same place we define our try/catch wrapper for the analytics: that logic would provide a context that gives access to an infinite promise in case the process should never end (like here when we run a server), and a reject callback to end the process in a controlled way.
Not saying we should do this, but perhaps worth considering it in this situation 👍
Okay I checked it out. This is a super neat idea as well. IMO I feel like the current promise passdown method will be a bit easier to debug if we ever need compared to exiting via local storage where it won't be as easily traceable.
If it's okay with you I'd like to keep the current implementation.
We detected some changes at packages/*/src and there are no updates in the .changeset.
If the changes are user-facing, run pnpm changeset add to track your changes and include them in the next release CHANGELOG.
[!CAUTION] DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release.
Do you know which part of the code starts showing errors if we don't return a promise here? Is it the ctx.localThemeFileSystem.startWatcher that we can within setupDevServer?
Without the extra promise, the rejection propagates through each of the rejectPromise areas which causes the error to render multiple times. This promise stop the initial chain and creates a single spot where we throw the error and you see it rendered in the terminal. I added a better comment above it to explain why it's there!