The map page’s bottom sheet refused to shrink from MEDIUM to SMALL. It would only grow to LARGE. Users could drag down all they wanted; the sheet stayed where it was. The fix turned out to be a single line, but that line is a story about a recurring trap — relying on object-key insertion order.
1. Diagnose
Inside useStandardBottomSheet there was sheet-size transition logic that looked roughly like this:
const sizes = Object.keys(parsedSnapPoints) as Size[]
const currentIdx = sizes.indexOf(sheetSize)
if (touchMove.movingDirection === 'DOWN' && currentIdx > 0) {
return sizes[currentIdx - 1] // smaller sheet
}
if (touchMove.movingDirection === 'UP' && currentIdx < sizes.length - 1) {
return sizes[currentIdx + 1] // larger sheet
}
The intent reads sensibly enough — “move one step smaller or larger from the current index.”
The trouble was the order in which parsedSnapPoints was being constructed. Following the call site, the object was built as { MEDIUM, LARGE, SMALL }. ES2015+ does guarantee insertion order is preserved for string keys, but nothing in the spec promises insertion order matches domain order.
So Object.keys(parsedSnapPoints) returned ['MEDIUM', 'LARGE', 'SMALL'] — meaning MEDIUM=0, LARGE=1, SMALL=2. Sheet sizes go SMALL < MEDIUM < LARGE, but indices were MEDIUM < LARGE < SMALL. The index ordering and the domain ordering had silently drifted apart.
2. Reproduce
Laying it out as a table makes the bug obvious.
| sheetSize | direction | currentIdx | expected | actual |
|---|---|---|---|---|
| MEDIUM | DOWN | 0 | go to SMALL | (no movement) |
| MEDIUM | UP | 0 | go to LARGE | LARGE |
| LARGE | DOWN | 1 | go to MEDIUM | MEDIUM |
When sheet is MEDIUM and direction is DOWN, the guard currentIdx > 0 becomes 0 > 0 → false, so the transition is silently dropped. UP is fine: 0 < 2 → true, indexing into LARGE. The SMALL → anywhere case happened rarely enough that the bug went unnoticed for a while.
You could’ve worked around it by changing the call site to build the object as { SMALL, MEDIUM, LARGE }. But that introduces a fragile rule — “every place that constructs this object must preserve a magic order” — which scales poorly as call sites grow.
3. Fix
Sort the keys by value once, so that the index and the sheet size mean the same thing inside the function.
const sizes = (Object.keys(parsedSnapPoints) as Size[])
.sort((a, b) => parsedSnapPoints[b] - parsedSnapPoints[a])
A snap point’s value is the y-coordinate measured from the top of the screen. The larger the y, the lower (and smaller) the sheet. Sorting by descending y gives:
['SMALL', 'MEDIUM', 'LARGE']
idx 0 idx 1 idx 2
Now MEDIUM’s index is 1, so DOWN gives 1 > 0 → true → SMALL (idx 0), and UP gives 1 < 2 → true → LARGE (idx 2). MEDIUM sitting in the middle of the array makes both directions of transition natural.
The same PR also simplified some Android/iOS branching into app/web branching elsewhere in the file — but that’s a separate story. The one-line sort was the heart of this bug.
4. Prevent recurrence
A few things tightened up after this.
First, never assume Object.keys() / Object.values() / Object.entries() returns business-domain order. The spec guarantees “insertion order is preserved,” not “insertion order matches the order your domain logic expects.” Pass the object through JSON serialization, merge it with another object, or have a teammate refactor a constructor — and the assumption breaks.
Second, when writing index-based branching, materialize the sort within the function. The fix above sorts once, then the rest of the logic can trust the index meaning. It’s no longer at the mercy of however an upstream caller happens to build the object.
Third, ask whether the index ordering and the domain ordering are guaranteed to coincide. Here, sheet size was the domain; index was just “the order Object.keys happened to return.” When they accidentally match, the bug stays dormant; when an upstream change shifts the order, the bug surfaces — often far from where the constructor was modified. The temporal distance between introduction and discovery is what makes this class of bug painful.
A single sort line determined whether the whole interaction worked. It can be tempting to skip the sort to keep code lean, but tying domain order to upstream insertion order is risky. Sort once, then write simple downstream logic — that’s almost always the safer trade.