'),e}(),r=i&&i.contentDocument||document,d=r.body,l=r!==document;(t=r.createElement("div")).style.cssText="width: 1px; height: 1px; position: relative; top: 0px; left: 32000px;",l||(e=function(){var e,t,o=document.documentElement,n=document.body,i=a?window.getComputedStyle(o,null):o.currentStyle,r=a?window.getComputedStyle(n,null):n.currentStyle,d=(i.overflowX||i.overflow||"visible").toLowerCase(),l="hidden"!==(r.overflowX||r.overflow||"visible").toLowerCase(),u="visible"===d,c={documentElement:{modified:u},body:{modified:l}};u&&(e=o.style,c.documentElement.styleOverflowX=e.overflowX,e.overflowX="auto");l&&(t=n.style,c.body.styleOverflowX=t.overflowX,t.overflowX="hidden");return c}()),o=d.scrollWidth,d.appendChild(t),n=o!==d.scrollWidth,d.removeChild(t),l||function(e){e.documentElement.modified&&(document.documentElement.style.overflowX=e.documentElement.styleOverflowX);e.body.modified&&(document.body.style.overflowX=e.body.styleOverflowX)}(e),u=n?"documentElement":"body",i&&document.body.removeChild(i)}function v(e,t){var o=t.documentElement;return Math.max(o.body["scroll"+e],t["scroll"+e],o.body["offset"+e],t["offset"+e],t["client"+e])}function y(e){return h("Width",e)}function h(e,t){var o,n,i=(t||window).visualViewport?(t||window).visualViewport[e.toLowerCase()]:(t||window)["inner"+e];return i&&(o=i,r||((n=o)!==+n||n===(0|n))||(r=!0)),i}function b(){return!!r}function g(e){return null!=e&&e.window==e}function x(e){return"string"==typeof e||e&&"object"==typeof e&&"[object String]"===Object.prototype.toString.call(e)||!1}function E(){var e;return void 0===n&&(n=!1,e=navigator&&navigator.userAgent,navigator&&"Microsoft Internet Explorer"===navigator.appName&&e&&null!=new RegExp("MSIE ([0-9]{1,}[.0-9]{0,})").exec(e)&&(n=parseFloat(RegExp.$1))),n}if(e.documentWidth=function(t){var o;t||(t=document);try{void 0===u&&s(),o=t[u].scrollWidth}catch(e){o=v("Width",t)}return o},e.documentHeight=function(t){var o;t||(t=document);try{void 0===u&&s(),o=t[u].scrollHeight}catch(e){o=v("Height",t)}return o},e.windowWidth=function(e,t){return d("Width",l(arguments))},e.windowHeight=function(e,t){return d("Height",l(arguments))},e.pinchZoomFactor=function(e){return w(e)},e.scrollbarWidth=p,"function"==typeof e&&!((i=E())&&i<8)&&9!==E())try{e(function(){void 0===u&&s(),p()})}catch(e){}}("undefined"!=typeof jQuery?jQuery:"undefined"!=typeof Zepto?Zepto:$); //# sourceMappingURL=jquery.documentsize.min.js.map // jQuery.isInView, v1.0.6 // Copyright (c) 2014-2017 Michael Heim, Zeilenwechsel.de // Distributed under MIT license // http://github.com/hashchange/jquery.isinview !function(a){"use strict";function b(b,c){var d,e,f,g,h,i,j,k={target:{}},o={},p={},q=b[0];if(b=b.eq(0),c||(c="both"),k.getBoth="both"===c,k.getHorizontal="horizontal"===c||k.getBoth,k.getVertical="vertical"===c||k.getBoth,"horizontal"!==c&&"vertical"!==c&&"both"!==c)throw new Error("Invalid parameter value: axis = "+c);if(b.length)return k.target.isWindow=a.isWindow(q),k.target.isWindow?p.document=q.document:9===q.nodeType?(p.document=q,k.target.isWindow=!0):q===q.ownerDocument.documentElement?(p.document=q.ownerDocument,k.target.isWindow=!0):1===q.nodeType&&"iframe"===q.tagName.toLowerCase()?(p.document=q.contentDocument||q.contentWindow.document,k.target.isWindow=!0):q===q.ownerDocument.body&&(p.document=q.ownerDocument,k.target.isBody=!0),(k.target.isWindow||k.target.isBody)&&(p.window=p.document.defaultView||p.document.parentWindow,p.$document=a(p.document),p.documentElement=p.document.documentElement,p.body=p.document.body,q=b=void 0),k.target.isWindow?o=l(k,p):k.target.isBody?(f=m(k,p).body,d=a(p.body),k.getHorizontal&&(o.horizontal=f.overflowScrollX||f.overflowAutoX&&p.body.clientHeight0&&(e.overflowScrollX||e.overflowAutoX&&(g=b.innerWidth())0&&(e.overflowScrollY||e.overflowAutoY&&(h=b.innerHeight())0&&a.offsetHeight>0)&&(b.useHorizontal&&(c=s(k,b.containerIsWindow,l)),b.useVertical&&(d=t(k,b.containerIsWindow,l)),e=void 0!==l.hTolerance?l.hTolerance:l.hTolerance="add"===b.toleranceType?b.tolerance:c*b.tolerance,f=void 0!==l.vTolerance?l.vTolerance:l.vTolerance="add"===b.toleranceType?b.tolerance:d*b.tolerance,g=b.borderBox?a.getBoundingClientRect():j(a),b.containerIsWindow||(g=i(g,k,l)),b.partially?(b.useVertical&&(m=g.top-f),b.useHorizontal&&(m=m&&g.left-e)):(b.useVertical&&(m=g.top>=-f&&g.top-f&&g.bottom<=d+f),b.useHorizontal&&(m=m&&g.left>=-e&&g.left-e&&g.right<=c+e)),m)}function i(a,b,c){var d;return c&&c.containerPaddingRectRoot?d=c.containerPaddingRectRoot:(d=k(b[0]),c&&(c.containerPaddingRectRoot=d)),{top:a.top-d.top,bottom:a.bottom-d.top,left:a.left-d.left,right:a.right-d.left}}function j(a){var b=a.getBoundingClientRect(),c=w(a,["borderTopWidth","borderRightWidth","borderBottomWidth","borderLeftWidth","paddingTop","paddingRight","paddingBottom","paddingLeft"],{toFloat:!0});return{top:b.top+c.paddingTop+c.borderTopWidth,right:b.right-(c.paddingRight+c.borderRightWidth),bottom:b.bottom-(c.paddingBottom+c.borderBottomWidth),left:b.left+c.paddingLeft+c.borderLeftWidth}}function k(a){var b=a.getBoundingClientRect(),c=w(a,["borderTopWidth","borderLeftWidth"],{toFloat:!0});return{top:b.top+c.borderTopWidth,left:b.left+c.borderLeftWidth}}function l(b,c){var d,e,f,g=a.scrollbarWidth(),h={},i=!b.getHorizontal,j=!b.getVertical;return b.getHorizontal&&g>0&&(d=c.window.innerHeight)>10&&(h.horizontal=d>c.documentElement.clientHeight,i=!0),b.getVertical&&g>0&&(e=c.window.innerWidth)>10&&(h.vertical=e>c.documentElement.clientWidth,j=!0),i&&j||(f=m(b,c).window,!i&&f.overflowScrollX&&(h.horizontal=i=!0),!j&&f.overflowScrollY&&(h.vertical=j=!0),!i&&f.overflowHiddenX&&(h.horizontal=!1,i=!0),!j&&f.overflowHiddenY&&(h.vertical=!1,j=!0),i||(h.horizontal=c.documentElement.clientWidth Skip to main content

Two Payloads, One Body, Dead Parser

Debugging a phantom JSON error in the Breakdance builder, and the diagnostic plugin that turned out to be the disease.

Worked out in one short, brutal evening with my buddy Claude Opus 4.8, who ran the forensics like a full-grown Fable or Mythos — the ones we’re all quietly missing right now.

The Breakdance builder wouldn’t open. Every time I tried to edit a page, a red toast slid in from the corner:

WordPress AJAX Request failed — Unexpected non-whitespace character after JSON at position 11183 (line 131 column 2)

I’d lost the better part of an afternoon to it before admitting I had no idea what was actually wrong. I’d done the obvious things. Disabled every plugin. No change. Cleared caches. No change. Then I curl‘d the REST endpoint to see the raw response, and there it was at the very end of the JSON: a stray %. Found you, I thought. Corruption, right there in the body.

It was not, in fact, found.

The red herring

That % is zsh telling me the output didn’t end in a newline. It’s a reverse-video marker for a partial final line, printed by the shell, not present in the response. Plenty of perfectly valid JSON APIs skip the trailing newline. I’d spent real time squinting at a prompt artifact as if it were a byte on the wire.

The lesson landed before the fix did: the parser doesn’t lie about position, and I’d been reading the response as text instead of as bytes. So I stopped looking at characters and looked at the actual hex.

curl -s 'https://example.studio/wp-json/' | tail -c 64 | xxd

The index endpoint came back clean. It ended on 7d — a closing brace — with nothing after it. Which meant the corruption wasn’t global. It was specific to the one request that kept failing, the authenticated load the builder fires. So I grabbed that one with its cookies intact: DevTools → Network → the failing request → Copy as cURL, replayed in the terminal, piped through xxd.

This time the tail told a different story. Right around position 11183:

7d 0a 7d 7b   →   } \n } {

Two complete JSON documents, glued end to end. The builder’s real payload closes with }, and then a second object begins, {, with no separator. The browser parsed the first one happily, reached the { that had no business being there, and gave up on the spot.

Two payloads, one body, dead parser.

What was in the second body

The response headers explained the mood: 500 Internal Server Error, served as application/json, X-Powered-By: PHP/8.5.6. Something was throwing partway through the request, after the real JSON had already been flushed.

I pulled the second object out on its own:

python3 -c "import json; d=open('/tmp/bd.json').read(); print(json.dumps(json.loads(d[11183:]), indent=2))"

And got a confident, detailed exception that was completely beside the point:

ErrorException: httpresponsecode(): Calling httpresponsecode() after header(‘HTTP/…’) has no effect in …/breakdance/…/Whoops/Util/SystemFacade.php

Breakdance bundles Whoops as its error handler. Reading the trace bottom-up, the sequence was almost comic: some earlier error fires, Whoops wakes up to report it, tries to write its error JSON to the output and flip the status to 500 — but the builder’s real payload has already gone out and the headers are committed, so http_response_code(500) has no effect and emits a warning of its own, which Whoops also catches and promotes to the exception I was now staring at.

The error I’d captured was the handler tripping over its own shoelaces. The thing that started the fall was still masked underneath it.

The PHP detour

PHP 8.5.6 is bleeding edge, and nothing in my stack had been tested anywhere near it, so I dropped the domain to 8.3 in the host panel (8.0 and 8.1 were both flagged end-of-life; 8.3 is current and still supported). The http_response_code cascade vanished. The 500 did not.

That was the useful part. If a version change kills the symptom but not the failure, the root cause isn’t about the version. Time to stop guessing and force the original error to show itself.

The unmask

With the secondary noise out of the way, Whoops finally surfaced the real one in its own details panel:

Function _load_textdomain_just_in_time was called incorrectly. Translation loading for the health-check domain was triggered too early. … (This message was added in version 6.7.0.)

The stack ran straight through Health_Check_Files_Integrity::__construct()__()_doing_it_wrong()trigger_error().

So here’s the whole chain, finally end to end. The Health Check & Troubleshooting plugin — the one I keep installed for, yes, troubleshooting — was loading its translations before the init hook. WordPress 6.7 started flagging exactly that with a _doing_it_wrong notice. On its own, that notice is advisory and harmless; you’d normally never see it. But Whoops promotes notices to exceptions. So a developer-facing warning became a thrown error, which became a 500, which appended a second JSON object to a response that already had one, which killed the parser with the most unhelpful message it could have picked.

The diagnostic tool was the disease.

The fix

Four moves, smallest blast radius first:

  1. Deactivate Health Check and its dependent add-on. That removed the load-time trigger immediately.
  2. Neutralise the whole class of problems with a one-line must-use plugin, so the next early-translation notice from anywhere can’t take the builder down with it:
    <?php
    // wp-content/mu-plugins/00-silence-doing-it-wrong.php
    add_filter( 'doing_it_wrong_trigger_error', '__return_false' );

    _doing_it_wrong() checks that filter before it ever calls trigger_error(). Return false and the notice is never raised, so Whoops never sees it. The 00- prefix makes the file load first, ahead of any other mu-plugin that might trip the same notice during its own load. It silences only these advisory notices; real errors and fatals are left entirely alone.

  3. Delete the orphaned drop-in. Deactivating Health Check leaves wp-content/mu-plugins/health-check-troubleshooting-mode.php behind, still instantiating itself and hooking the active-plugins list on every request. It’s meant to be removed when you leave troubleshooting mode. It wasn’t. Gone.
  4. Stay on PHP 8.3. Off the bleeding edge, still supported, and it disposed of the http_response_code cascade as a bonus.

Then I opened a page, made an edit, and saved. No toast. No 500. No second body.

What I’m taking away from it

The byte offset doesn’t lie. When the parser says position 11183, the answer is at position 11183 — in the actual bytes, not in whatever I’ve assumed the response looks like. A shell prompt character nearly cost me an evening that one xxd closed in about a minute.

The error you catch can hide the error you need. Whoops handed me a polished, fully-traced exception that was pure misdirection. Always ask whether the handler is reporting the problem or being the problem.

Debug handlers that turn notices into fatals are a foot-gun the moment something is parsing the response as JSON. Fine for rendering a dev error page. Lethal for an AJAX endpoint.

And keep your troubleshooting plugins out of production. The thing I’d installed to help me find conflicts was the conflict.


If you hit something like this and want the longer version, or you’ve got a Breakdance/WordPress gremlin of your own that won’t sit still, get in touch — I’m always happy to talk shop.

© Studio Waldbach 2026