zen-browser/desktop · warning · Error
Unexpected content type
Error message
Unexpected content type
What it means
Thrown by nsGithubLiveFolderProvider.parsePullRequests when a fetch to https://github.com/pulls returns a non-JSON body AFTER an earlier fetch already established that the JSON dashboard API is available (state.isJsonApi === true). GitHub serves /pulls either as JSON (new pulls-dashboard surface) or as classic HTML depending on account flags and A/B state; the provider tracks which mode it is in and uses the JSON path's payload.pullsDashboardSurfaceContentRoute.results when possible. The throw is intentional control flow: it flips state.isJsonApi back to false and signals that the current request's query params (built for the JSON API) are invalid for the HTML endpoint, so the next refresh must rebuild them. The outer fetchItems() try/catch at GithubLiveFolder.sys.mjs:97 catches it and surfaces the sentinel string "zen-live-folder-failed-fetch"; no exception reaches the UI.
Source
Thrown at src/zen/live-folders/providers/GithubLiveFolder.sys.mjs:122
const { text, status } = await this.fetch(url, {
headers: {
Accept: "application/json,text/html",
},
});
if (status !== 200) {
return { status };
}
let parsedJson = null;
try {
parsedJson = JSON.parse(text);
this.state.isJsonApi = true;
} catch {
if (this.state.isJsonApi) {
this.state.isJsonApi = false;
// throw to indicate user to re-try (Url may contain invalid params for non-json /pulls)
throw new Error("Unexpected content type");
}
}
if (parsedJson) {
const results =
parsedJson.payload.pullsDashboardSurfaceContentRoute.results;
const items = [];
const activeRepos = new Set();
for (const pr of results) {
activeRepos.add(pr.repoNameWithOwner);
items.push({
id: `${pr.repoNameWithOwner}#${pr.number}`,
title: pr.title,
subtitle: pr.author.displayLogin,
icon: "chrome://browser/content/zen-images/favicons/github.svg",
url: pr.permalink,View on GitHub (pinned to 89e31cd31f)
Solutions
- Do nothing in a custom caller — the throw is already handled. The provider flips state.isJsonApi to false, persists via requestSave on the next option change, and the subsequent refresh rebuilds queries through the HTML branch (parsePullRequests lines 151-189). One failed refresh is expected.
- If the live folder is stuck cycling between modes, flip the pref zen.live-folders.github.skip-new-pr-ui-check to true; this skips the initial JSON probe (lines 35-57) so the provider never commits to the JSON API and always uses the stable HTML parser.
- If the account no longer has the new pulls dashboard, sign out and back in at github.com (or wait for the A/B bucket to settle) so GitHub's response shape stops flipping; the provider will then settle on whichever mode GitHub consistently returns.
- Verify the GitHub session cookie is still valid in the Zen profile — an expired session can return a 200 HTML login page that fails JSON.parse and triggers the same path.
Example fix
// before: JSON.parse failure on a previously-JSON endpoint aborts the whole call
try {
parsedJson = JSON.parse(text);
this.state.isJsonApi = true;
} catch {
if (this.state.isJsonApi) {
this.state.isJsonApi = false;
throw new Error("Unexpected content type");
}
}
// after (defensive): tolerate the mode flip inline and fall through to the HTML
// branch instead of throwing, so one refresh degrades gracefully
try {
parsedJson = JSON.parse(text);
this.state.isJsonApi = true;
} catch {
if (this.state.isJsonApi) {
this.state.isJsonApi = false;
this.requestSave(); // persist the corrected mode
// fall through to HTML parsing below instead of throwing
}
} Defensive patterns
Strategy: try-catch
Validate before calling
// Probe the endpoint shape ONCE and record the mode before issuing the
// query-param'd request, so parsePullRequests never flips mid-session.
async function probeIsJsonApi(provider, url) {
const { text, status } = await provider.fetch(url, {
headers: { Accept: "application/json,text/html" },
});
if (status !== 200) return null; // let status handler deal with it
const ct = text.trimStart().slice(0, 1);
return ct === "{" || ct === "["; // cheap shape check
}
// then: if (!probeIsJsonApi) skip the JSON-only OR-grouped query Type guard
// Narrow a fetch response body to "JSON-shaped" without throwing.
function looksLikeJson(text) {
if (typeof text !== "string" || text.length === 0) return false;
const head = text.trimStart().slice(0, 1);
return head === "{" || head === "[";
}
// Usage inside parsePullRequests before JSON.parse:
// if (this.state.isJsonApi && !looksLikeJson(text)) {
// this.state.isJsonApi = false;
// this.requestSave();
// // fall through to HTML branch, do NOT throw
// } Try / catch
// Recommended: keep the throw confined to the provider's own fetchItems()
// try/catch (already in place at GithubLiveFolder.sys.mjs:97). Callers of
// fetchItems()/parsePullRequests() should treat any thrown Error as a
// transient fetch failure and surface the existing sentinel:
try {
const items = await provider.fetchItems();
if (typeof items === "string") {
// sentinels like "zen-live-folder-failed-fetch" — show UI state
handleLiveFolderError(items);
} else {
renderItems(items);
}
} catch (e) {
// Only reaches here if the outer try/catch itself threw — treat as fatal
console.error("live-folder unrecoverable", e);
} Prevention
- Treat state.isJsonApi as persistent profile state — call requestSave() whenever you flip it, so the mode survives restarts instead of being re-probed every session.
- Send Accept: application/json with the request so GitHub's content negotiation favours JSON when available; never rely on implicit content type.
- Gate the JSON code path on a positive probe (line 35-57) AND on the response body shape, not on JSON.parse succeeding — use the looksLikeJson guard above.
- Do not construct /pulls query strings that are only valid for one renderer (JSON OR-grouped vs HTML repeated params) unless you are certain of the mode; prefer the HTML-compatible param form which works in both branches.
When it happens
Trigger: A prior successful JSON.parse at line 116 set this.state.isJsonApi = true. On a later call, this.fetch(url, { headers: { Accept: "application/json,text/html" } }) returns HTTP 200 with a body that is NOT valid JSON (typically HTML), so JSON.parse at line 116 throws. The catch at line 118 sees this.state.isJsonApi is still true, sets it to false, and re-throws "Unexpected content type" at line 122. Concretely this happens when #buildSearchOptions emitted the single OR-grouped JSON query (line 302) but GitHub responded with the HTML view for that exact query string.
Common situations: GitHub moves the account out of the new pulls-dashboard A/B bucket (or the user toggles the new PR experience in GitHub settings) between two refreshes. GitHub serves an interstitial/HTML page (rate-limit page, login wall reflected as 200 HTML, anti-bot challenge) that is not the JSON dashboard. A query parameter only valid for the JSON API is sent while GitHub has temporarily routed the request to the HTML renderer. Clock/profile state restored from another machine where isJsonApi was true but the current account is in the HTML cohort.
Related errors
AI-assisted analysis of zen-browser/desktop@89e31cd31f (2026-08-13).
Data as JSON: /api/errors/564593bdf0e99adf.
Report an issue: GitHub.