The console said cls.forEach is not a function, which is JavaScript for the thing you thought was a list isn’t a list, and I’m not telling you what it is instead.
This was an admin screen — a shortcode builder for a cruise search plugin. Pick a cruise line from the first dropdown, the second dropdown fills with that line’s ships, tick the ones you want, out comes a shortcode. Two selects and about fifteen lines of jQuery. It had worked for a couple of years.
It had also, it turned out, been quietly wrong the whole time in a second place nobody had looked at.
The two symptoms
Symptom one: pick a cruise line, get an exception, second dropdown stays empty.
Symptom two — and this is the one that should have tipped me off years earlier — a different screen in the same plugin had a flat “all ships” dropdown. It listed about as many ships as there were cruise lines. Not a suspicious number. Just a short list where a long one should have been, and nobody counts the options in a dropdown.
Both screens read the same cached file. Both were built from the same assumption.
The feed
The plugin caches a handful of JSON files from the upstream data provider and builds its admin dropdowns from those rather than hitting the API on every page load. The destinations file looks like you’d expect:
[
{ "id": 41, "parentId": 41, "name": "Mediterranean" },
{ "id": 87, "parentId": 41, "name": "Western Mediterranean" }
]
Own id in id, parent in parentId, top-level rows point at themselves. Fine. That’s the shape you’d draw on a whiteboard.
The ships file uses the same two field names. It does not use them the same way round. In that file id is the cruise line’s id and parentId is the ship’s own id.
I want to be fair to whoever wrote the feed: there’s an internal logic to it, if you squint. It’s a ships-grouped-by-line collection, so the “id” of a row in that collection is arguably the group. It’s just that the file sitting next to it in the same directory, downloaded by the same importer, uses the opposite convention. And nothing in either file tells you which one you’re holding.
What that did to the PHP
The flat dropdown:
$ships = json_decode( file_get_contents( $cache . '/ships.json' ) );
foreach ( $ships as $obj ) {
$shipArr[ $obj->id ] = $obj->name;
}
$obj->id is the cruise line. So every ship on a given line writes to the same array key, and the last one wins. Twelve hundred-odd ships went in, one row per cruise line came out. No warning, no notice, no error — that’s just what PHP array assignment does. The dropdown rendered perfectly. It was simply mostly missing.
And the grouped map, on the other screen:
foreach ( $ships as $obj ) {
$shipArr[ $obj->parentId ][] = array( 'id' => $obj->id, 'name' => $obj->name );
}
?>
<script>let linesToShips = <?php echo json_encode( $shipArr ); ?></script>
Keyed by parentId, which here is the ship. So linesToShips came out as a map of ship id to an array of exactly one ship — twelve hundred groups of one. Then the JS asked it for a cruise line id, got undefined, and called .forEach() on it.
Same wrong assumption. Two completely different failure modes. One of them screams and one of them doesn’t, and the quiet one had been shipping for years.
The other thing living in that file
Both of those blocks are in one settings template. One PHP scope, top to bottom, about a thousand lines. Neither block initialised its accumulator:
// block one, around line 275
foreach ( $ships as $obj ) {
$shipArr[ $obj->id ] = $obj->name; // values are strings
}
// block two, around line 555, same scope, same variable
foreach ( $ships as $obj ) {
$shipArr[ $obj->parentId ][] = array( ... ); // values are arrays
}
Block two didn’t start from empty. It started from whatever block two hundred and eighty lines earlier had left behind. So linesToShips shipped to the browser carrying a pile of leftover strings alongside the arrays it was supposed to contain, and any key that appeared in both — a ship id that happens to match a cruise line id — would have tried to append to a string. PHP 8 does not let you do that. It’s an Error, which on an admin page means a white screen.
As far as I can tell those key spaces don’t overlap today. That’s not a design decision, that’s luck, and it’s the upstream provider’s luck to change.
There’s no clever fix here. Four lines:
$destArr = array(); $portArr = array(); $lineArr = array(); $shipArr = array();
Long templates in one scope are their own bad idea, and I’m not going to pretend I’ve solved that. But if you’re going to have them, declare the thing before you fill it.
The fix
Swap the field back where it belongs and leave a note, because the next person to read that loop will make exactly the same assumption I did:
// NOTE: in ships.json the ship's own id is `parentId`; `id` holds the
// cruise line id - the reverse of destinations.json. Keying on `id`
// collapsed 1270 ships down to one row per cruise line.
$shipArr = array();
foreach ( $ships as $obj ) {
$shipArr[ $obj->parentId ] = $obj->name;
}
asort( $shipArr, SORT_NATURAL | SORT_FLAG_CASE );
And for the grouped one, plus a cast on the way out:
$shipArr = array();
foreach ( $ships as $obj ) {
$shipArr[ $obj->id ][] = array( 'id' => $obj->parentId, 'name' => $obj->name );
}
?>
<script>let linesToShips = <?php echo json_encode( (object) $shipArr ); ?>;</script>
That (object) cast is worth its own paragraph. json_encode() on a PHP array gives you a JSON array when the keys happen to be 0, 1, 2, … and a JSON object otherwise. Which means the shape of your data on the JavaScript side depends on whether the ids you got back this week started at zero. Casting to stdClass first forces an object every time. If you’re building a lookup map to hand to the front end, cast it. It costs nothing and it removes a whole category of Tuesday afternoon.
The JS wanted tightening too:
$('#line_id').on('change', function () {
var v = $(this).val();
$('#ship_ids option').remove();
if (v === '' || v === null) return;
var ships = linesToShips[v] || [];
ships.forEach(function (s) {
$('#ship_ids').append($('<option>', { value: s.id, text: s.name }));
});
});
cls was previously an implicit global — no var, no let, nothing. Which meant it leaked to window and survived between events, so a failed lookup left the previous line’s ships sitting in the variable. Adding || [] means a missing key gives you an empty loop rather than an exception. That’s not a fix for the actual bug and I don’t want to pretend otherwise. It’s a fix for the bug being unreadable.
The bit that’s still broken
Here’s my favourite part.
There’s a third place in the plugin that reads the same file — the front-end shortcode that renders the ship links. Same loop, same wrong key, and it caches the result in a transient for a week:
foreach ( $ships as $obj ) {
$shipArr[ $obj->id ] = $obj->name; // still the cruise line id
}
set_transient( $transName, $shipArr, WEEK_IN_SECONDS );
The renderer then walks the ship ids saved in the shortcode and does array_key_exists( $ID, $data ) against that map before emitting a link. Which is to say: now that the builder produces correct ship ids, the renderer will fail to find nearly all of them and silently emit nothing. Fixing the admin screen made the front end worse.
It wasn’t in the commit. It’s next.
What I’d take from this
Two things, and they’re not the same thing.
The first is about names. id and parentId are not a schema, they’re a convention, and a convention only holds inside whatever boundary someone was thinking about when they wrote it. Two files from one API, downloaded by one importer, sitting in one directory — that felt like a boundary to me. It wasn’t one. If you’re mapping a feed you don’t control, key off something you’ve verified in that file, not off the shape of the file next to it.
The second is about how the bug announced itself. One code path threw a TypeError the first time anyone touched it. The other quietly discarded ninety-odd percent of its rows and rendered a tidy, plausible, completely wrong dropdown for two years. Same defect. The loud one got fixed the day it appeared.
I don’t have a tidy method for finding the quiet ones. Counting things helps — if a list is built from twelve hundred records and renders eleven, something ought to say so out loud.
