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

  1. 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.
  2. 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.
  3. 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.
  4. 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

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.