For more than four days, the search overlay on an editorial site I built returned zero results in production. No error, no white screen — just "no matches" for every query, including words that obviously existed in the articles. Locally? Flawless. That's the worst combination: a bug that only exists where you can't comfortably inspect it.
The first clue: one missing key
I opened the console in production and inspected the global object the search used:
Object.keys( window.X );
// → ['ajaxUrl', 'nonce', 'restBase', 'locale'] (4 keys)Four keys. There should have been five. searchIndex — the array holding all the search data — was gone. Without that index, every search trivially returns zero results. The question was: why was the index present locally but missing in production?
The cause: wp_localize_script reassigns, it doesn't merge
Here's how I was injecting the search data. In the header, an inline <script> populated the index:
<!-- in the header -->
<script>
window.X = window.X || {};
window.X.searchIndex = [ /* ...large search dataset... */ ];
</script>Then, in the footer, I localized some config data onto the same global object via wp_localize_script:
wp_localize_script( 'my-search', 'X', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_search' ),
'restBase' => esc_url_raw( rest_url() ),
'locale' => get_locale(),
] );Here's the trap: wp_localize_script does not merge into an existing object. It emits a full declaration that reassigns the entire variable:
// HTML emitted by wp_localize_script, in the footer:
var X = { "ajaxUrl": "...", "nonce": "...", "restBase": "...", "locale": "..." };That var X = {…} is a total overwrite. Because the footer always executes after the header, this declaration replaces my window.X object holding searchIndex with a brand-new object that only has four keys. The footer always wins. The index was silently buried.
Why did it work locally? Enqueue order/timing and caching can differ between environments, but the core issue is structural: once both snippets run in header-then-footer order, the reassignment is guaranteed to wipe the index. I just got "lucky" locally.
The fix: merge with Object.assign, don't reassign
The solution is to stop letting WordPress emit a reassigning var X = {…}, and replace it with a non-destructive merge. I swapped wp_localize_script for wp_add_inline_script using Object.assign:
$config = [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_search' ),
'restBase' => esc_url_raw( rest_url() ),
'locale' => get_locale(),
];
wp_add_inline_script(
'my-search',
'window.X = Object.assign( window.X || {}, ' . wp_json_encode( $config ) . ' );',
'before'
);Two crucial details:
Object.assign( window.X || {}, … )merges the config keys into the existing object, preserving thesearchIndexalready set in the header.- The
'before'position ensures this inline script runs before the search code that depends on it.
After that, Object.keys(window.X) showed all five keys again, and the search overlay worked in production immediately.
The general rule to remember
wp_localize_script is designed to declare a data object, not append to one. Whenever two sources write to the same global name, they clobber each other:
- Don't use
wp_localize_scripton a global name that's already been populated elsewhere. It reassigns, it doesn't merge. - If you need to combine data from multiple sources onto one global, use
wp_add_inline_script+Object.assign( window.X || {}, … ). - Mind the execution order: the footer runs after the header. Whatever is declared last wins.
- To diagnose, compare
Object.keys(window.X)in production vs locally. A missing key points straight at an overwrite, not at data that failed to load.
The lesson
Four days of zero results, and the cause wasn't broken search logic — it was a single WordPress call that reassigned a global variable instead of merging into it. wp_localize_script emits a full var X = {…}, and because the footer always wins over the header, it erased the index I'd assembled. When a global appears to "lose" data between parts of a page, don't suspect the data first — suspect who wrote to that name last. To combine instead of clobber: Object.assign.
