On August 2nd, Apple rejected Feastmark under Guideline 2.1 — Information Needed. Seven questions in the Resolution Center, all of them reasonable, none of them about a defect.
I could have answered them in place. Instead I pulled the submission, because build 9 shipped with two bugs I already knew about and I didn’t want a reviewer finding them independently while I explained myself.
That felt like the responsible call. It was also the start of twenty-six days and fourteen builds. I resubmitted it today; here’s what happened in between.
The Bug That Ate Five Builds
Cook Mode shows a recipe’s steps as full-screen swipeable pages. Long steps have to scroll, and nothing said so — you’d read to the bottom of a card and assume that was the whole step. Simple problem. Add a cue.
Build 11 put the ingredients inline and clipped long instructions with no indication you could scroll. Build 12 added a cue tied to real scroll state: too small to notice, and the system scroll-bar flashed and faded next to it. Build 13 replaced it with a “Scroll for more” pill drawn on the card — which sat on top of the step’s last line and cut it off mid-word, so it read as broken text rather than as a cue. Build 14 tried to reserve space with content padding. The on-device screenshot came back pixel-identical to build 13: padding only adds room at the end of the scroll content, so anywhere above the bottom it was off screen doing nothing.
Build 15 stopped putting anything on top of the text. Fade the step into the card behind a gradient, move the cue to the corner.
Five builds for a scroll hint. Not one of them was a hard problem. Every one of them was me being confident about something I hadn’t looked at.
The Note I Left Myself
I wrote this in the review-response doc on August 2nd, right after build 15:
This bug took three attempts because the first two reasoned about layout instead of looking at it, and no test fixture had an overflowing step. CI and the 121-test suite were green for every one of them. For anything that is a rendering question, reproduce it on screen before believing it is fixed.
Correct diagnosis. Correct prescription. Written down in the repo, in the file I’d be opening every time I touched the submission.
Hold onto that. It comes back.
It’s Probably the Thing I Just Changed
Mid-month, a new report: an AllRecipes link wouldn’t import. Share it in, and what comes out is a draft with a garbage title and no ingredients.
I’d shipped unit conversion a few days earlier — metric/US switch, converts the whole recipe including the step text. So I went and read the conversion code. Obviously.
There was a bug in there. A recipe reading 1/2c. (107 g) would convert the first half and leave the bracket stranded, so Metric mode showed you 107 g). Genuine defect. I fixed it and felt productive for about twenty minutes.
It had nothing to do with the import.
I know better than this, which is the annoying part. The thing you touched most recently isn’t the most likely culprit, it’s just the one your brain reaches without effort.
It’s the Cloudflare Wall
Right. The symptom.
The import runs through a small API that fetches the page and pulls the recipe out of it, so I asked the API what it was seeing. A 402, and a 403 before that with a cf-mitigated: challenge header and a challenge script in the body.
Which I already knew about. AllRecipes is behind a Cloudflare JavaScript challenge, and I’d diagnosed it at the end of July and written it down so I wouldn’t have to twice: not IP-based, not header-based, and no amount of User-Agent theatre gets you through. Cloudflare fingerprints the TLS handshake, so no server-side HTTP client passes by pretending to be Safari. AllRecipes, SimplyRecipes and Serious Eats share a publisher, which puts a good chunk of the American recipe web behind one wall.
Known problem, known cause, close the tab.
Except we’d already fixed that. On the 10th. That was the whole point of the previous piece of work.
The fix was to stop asking a server to fetch the page, because the phone in your hand already had. Tap Share in Safari and it will run a small JavaScript file inside the page you’re looking at, then hand the result to the extension. The page is rendered, the challenge is passed, and the recipe is right there in the DOM in a schema.org JSON-LD block — the same structured data a different Feastmark bug taught me to read before writing a prompt — a few kilobytes of clean data on a 1.9 MB page.
I still had the log line from the spike:
blocks: 1 recipe: YES html: 1892335 chars
Shared out of Safari, into the extension, off a page behind the challenge. Fine.
So the wall wasn’t it either. The wall was real and we’d already got over it. Something was stopping us reaching the ladder.
It’s the Code
Which left the harvest code. The Swift that takes Safari’s payload and pulls the recipe out.
Eleven tests on it, all passing. I read it three times over about two hours and couldn’t find anything wrong with it.
There wasn’t anything wrong with it. It never ran.
Four Lines of XML
<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
<integer>1</integer>
An iOS share extension declares what it’s willing to accept. Ours said: URLs, and text. It never said web pages.
And NSExtensionJavaScriptPreprocessingFile, the key naming the JavaScript Safari runs inside the page, only fires if the extension has declared it accepts a web page. Without that line Safari decides the extension wants a URL and hands over a bare URL. The preprocessing file doesn’t execute. Nothing reads the DOM. The JSON-LD is sitting right there and nobody goes and gets it.
So the extension handed the URL to the server, and the server walked into the Cloudflare wall the way it had all along. Every line of the work to get over that wall was compiled into the binary, correct, covered by tests, and unreachable because of a declaration nobody had written.
The spike had passed — in the simulator, which activates the extension and harvests the page without that key. The device doesn’t. Twelve days of believing a feature had shipped, because it had: into a binary that was never asked to run it.
The glib lesson is “test on a real device,” and sure. But that’s not what went wrong. What went wrong is that a test existed, and was green, and was green on the wrong machine.
That’s worse than having no test at all. A missing test leaves you nervous, and nervous is useful. A green check is a persuasive argument that the code isn’t the problem, and I believed it for two hours while reading perfectly innocent Swift. It never occurred to me to ask whether the code was running, because the test said it ran.
The Same Bug, Twenty-Six Days Later
Now the part I’d rather not write.
Then, on build 22, the scroll cue was broken again. Not the rendering this time: tapping the pill jumped you to the next step instead of scrolling.
Obviously a gesture conflict. The tap is getting eaten by the page-swipe recognizer. Attempt one: make the pill a Button, on the assumption the tap reached it and did nothing. Attempt two: move the pill up 32pt to clear the indicator strip — a guess at pixel geometry I could not measure from where I was sitting.
Both compiled. Both passed all 289 tests. Both were wrong, and neither could have worked.
It wasn’t gestures. SwiftUI’s paged TabView draws its page-dot indicator — a UIPageControl — above its pages, and that control’s touch target spans the bottom of the card. The pill is an overlay inside a page, so it sits underneath, and nothing layered below a control wins a tap against it.
.tabViewStyle(.page(indexDisplayMode: .never))
Remove the control, remove the conflict. Nothing was lost with the dots either — the caption under the card already reads “Step 1 of 6 · swipe for the next,” which is more explicit and was already doing the job. The dots duplicated it while breaking the cue underneath them.
Six attempts at one scroll cue, across twenty-six days. And the note I wrote on August 2nd — reproduce it on screen before believing it is fixed — was in a file I had open that week.
Writing the lesson down is not the same as learning it. I had the diagnosis, in my own words, in the repo, and I still spent two more builds reasoning about a layout instead of looking at one.
What I’d Take From This
Check that the code you’re staring at is code that runs, before you read it a third time.
When the tests are green and the phone is red, the phone wins. The suite went from 121 tests to 289 across these twenty-six days and caught none of this. Nothing in it touches hit-testing, and nothing in it can touch what a property list declaration does to Safari’s share sheet. There was no assertion I could have added. A phone is the only instrument that detects either one.
And the one I clearly still need: a lesson you’ve written down is not a lesson you’ve absorbed. The note was right, it was specific, it named the exact failure mode, and it didn’t stop me repeating it at the far end of the same month. What would have stopped me is a fixture or a checklist — something mechanical, that doesn’t depend on me remembering to be humble at the right moment.
Twenty-six days, fourteen builds, three bugs with the same shape: a symptom pointing at an innocent layer, tests that were green somewhere that couldn’t see the failure, and one line of configuration doing the damage. A rendering order. An activation rule. Things that don’t appear in a diff, don’t throw, and don’t log. They just quietly don’t happen, and there’s nothing to grep for.
Feastmark was back in App Review as I wrote this. It has since cleared. I didn’t find any of it in the file I opened first.
Feastmark is on the App Store from Big Beard Apps.