Skip to content

Pressing Back after an in-page anchor replaces every statically-built page with an "Application Error" screen #186

Description

@mmcky

Found in the design review of the v2.5.0 deploy (6d773b63c, https://6a9b93b9108dd4beb6bebadc--epic-agnesi-957267.netlify.app), item 5. On a myst build --html deploy, clicking any "On this page" entry — or any in-content cross-reference — and then pressing the browser Back button once destroys the page: the whole document is replaced by a bare white screen reading "Application Error" with a JavaScript stack trace under it. Every lecture page on a static host is one Back press away from this, including the landing page. It is not a Netlify quirk; it needs only that GET /page/?_data=root return the page's HTML instead of loader JSON, which is what every static file server does.

Reproduction

Open any page on a static build, click an outline entry, press Back once. Measured four ways:

Server MODE Engine ?_data= responses on Back Result
Netlify (the reviewed deploy), /python-by-example/ static Chromium ?_data=root and ?_data=routes%2F%24, both 200 text/html; charset=UTF-8 2 page errors, <title>Application Error!</title>, body is the stack
python3 -m http.server over tests/visual/fixture built with myst build --html static Chromium /?_data=root and /?_data=routes%2F_index, both 200 text/html Application Error
Same local static build static WebKit same Application Error
Same fixture under myst start app Chromium both 200 application/json; charset=utf-8 0 errors, article renders normally

The last row is the point: the bug exists only in static builds, and it is reproducible on the repo's own fixture with no Netlify, no CDN and no redirects involved.

The thrown error differs by route, and both strings need to be in anyone's search:

Route Throwing line in v2.5.0 Console text Deployed module
Lecture page (routes/$) app/routes/$.tsx:23const page: PageLoader['frontmatter'] = data.page.frontmatter; TypeError: Cannot read properties of undefined (reading 'frontmatter') /build/routes/$-N6Q43WQ4.js
Landing page (routes/_index) app/routes/_index.tsx:23title: config?.title ?? project.title, TypeError: Cannot read properties of undefined (reading 'title') /build/routes/_index-XSHLEO5O.js

The screen is unstyled because two boundaries fail in sequence. app/root.tsx:25 exports AppErrorBoundary from @myst-theme/site, and it does catch the throw — but AppErrorBoundary (node_modules/@myst-theme/site/src/pages/Root.tsx:167-178) renders <Document> again, Document renders <Meta/>, and <Meta/> re-runs the same throwing meta(). The boundary therefore throws during its own render and Remix falls back to RemixRootDefaultErrorBoundary (node_modules/@remix-run/react/dist/errorBoundaries.js:89,98): a bare <html> with <title>Application Error!</title>, an <h1>Application Error</h1> and a <pre> of error.stack. That is exactly what I measured — the page title after Back is literally Application Error!.

Mechanism

myst build --html runs the theme with MODE=static. app/root.tsx:201 reads it from the root loader and app/root.tsx:211 passes staticBuild={MODE === 'static'} to upstream Document. Document (@myst-theme/site 1.3.0, src/pages/Root.tsx:60-69) responds by swapping the link provider to <Link reloadDocument> — but <Scripts /> at src/pages/Root.tsx:150 renders unconditionally, so the Remix data router hydrates in static builds too. The deployed /build/manifest-DADB2520.js carries "hasLoader":true on root, routes/$ and routes/_index, so there are live loaders on a host that cannot serve them.

React Router does short-circuit hash-only navigations — router.js:1658 guards startNavigation with isHashChangeOnly(state.location, location) — but isHashChangeOnly (node_modules/@remix-run/router/dist/router.js:3673-3690) deliberately returns false when the hash is removed, which is exactly what Back from #anchor to the bare URL does. The function's own comment says why: "If the hash is removed the browser will re-perform a request to the server". So the navigation is not short-circuited, and getMatchesToLoad then sets defaultShouldRevalidate = true on the branch at router.js:3126 ("Clicked the same link, resubmitted a GET form") because currentUrl.pathname + currentUrl.search === nextUrl.pathname + nextUrl.search.

No route module in the theme exports shouldRevalidategrep -rn shouldRevalidate app/ on 6d773b63c returns nothing — so createShouldRevalidate (node_modules/@remix-run/react/dist/routes.js:110-128) falls through to arg.defaultShouldRevalidate at line 126 and the loaders re-run. The client fetches ?_data=root and ?_data=routes%2F%24; the static host answers 200 text/html; callLoaderOrAction takes the non-JSON branch data = await result.text() (router.js:3391-3395); and the loader value for both routes becomes the page's HTML source as a string. The guard at app/routes/$.tsx:19 (if (!data) return [];) passes, because a non-empty string is truthy, and the next property read throws. app/components/Page.tsx:21 ((data.page.frontmatter as any)?.site) would throw next even if meta() were hardened.

Sphinx baseline

Measured, not assumed. https://python-programming.quantecon.org/python_by_example.html has no client router at all (window.__remixContext is absent), its in-page anchors are bare href="#overview" fragments, and clicking one then pressing Back returns to the intact article. There is no Sphinx value to match here and no design question to answer — the parity target is simply that Back works, so this is a straight regression to repair rather than a divergence to decide.

Fix

Export a shouldRevalidate from the three route modules that render UI — app/root.tsx, app/routes/$.tsx and app/routes/_index.tsx. createShouldRevalidate prefers a route module's own function (@remix-run/react/dist/routes.js:123-124), unconditionally, once the module exports it.

Why this is the right guard, and why it is safe in both modes: a static build (myst build --html) still hydrates a live data router, but its loaders are only servable by a running Remix server, so on a static host ?_data=… returns the page HTML, @remix-run/router turns that into a string loader value (router.js:3391-3395), and every data.page read then throws. React Router short-circuits hash-only navigations except when the hash is removed (isHashChangeOnly, router.js:3687) — exactly what Back from #anchor does — so that POP would otherwise re-run the loaders. These three loaders are pure functions of pathname and search, so declining to revalidate when neither has changed is semantically correct in app mode too, not merely a patch for static builds.

import type { ShouldRevalidateFunction } from '@remix-run/react';

// A navigation that changes neither pathname nor search must not re-run these loaders.
export const shouldRevalidate: ShouldRevalidateFunction = ({
  currentUrl,
  nextUrl,
  formMethod,
  actionResult,
  defaultShouldRevalidate,
}) => {
  if (
    formMethod == null &&
    actionResult === undefined &&
    currentUrl.pathname === nextUrl.pathname &&
    currentUrl.search === nextUrl.search
  ) {
    return false;
  }
  return defaultShouldRevalidate;
};

I verified this end to end against the live deploy by intercepting /build/root-X3POM4TE.js and /build/routes/$-N6Q43WQ4.js and appending the export: after the outline click, Back issues zero ?_data= requests, logs zero errors, and renders the article normally.

All three modules are required, and a partial fix fails silently. Measured on the deploy:

Patched ?_data= requests on Back What happens
routes/$ only ?_data=root still fires Page looks correct, zero console errors — but root's loader data is the HTML string, so useLoaderData<SiteLoader>() at app/root.tsx:201 destructures undefined for theme, config, CONTENT_CDN_PORT, MODE and BASE_URL. staticBuild (root.tsx:211) flips to false and <ContentReload port={undefined} /> (root.tsx:210) mounts on a static deploy. Nothing announces it.
root only ?_data=routes%2F%24 still fires Application Error, unchanged
all three none Correct

Two approaches ruled out by measurement, so nobody re-litigates them. Rendering a plain <a href="#id"> in Outline.tsx instead of the provider Link changes nothing — the click is already a native same-document fragment navigation (I measured zero document requests and a surviving JS context on an outline click), and the failure is on the Back POP. And hardening data.page.frontmatter in meta() only changes which error screen you get: app/components/Page.tsx:21 throws on the next render, so you would see myst's styled ErrorUnhandled page instead of the raw stack. The page is destroyed either way.

Regression coverage must ship in the same PR

The current Playwright harness structurally cannot see this. tests/visual/serve.sh:29 is exec myst start --port "${PORT:-3111}", wired as webServer[0].command at playwright.config.ts:70-75 — that is MODE=app, where ?_data= returns real JSON and the bug does not exist. Merge the shouldRevalidate exports alone and the guard against reintroducing this is zero.

The missing piece is a small new Playwright project that serves a static build: copy tests/visual/fixture, substitute the theme into myst.yml the way serve.sh already does, run myst build --html, and serve _build/html with a plain static file server on its own port. I built exactly this while confirming the bug and it took about five minutes with no new tooling — python3 -m http.server over _build/html reproduced the failure in both Chromium and WebKit. The assertion is one line: after click-anchor-then-Back, the page title is not Application Error!. This is a cheap, permanent guard against a class of static-only defects the repo has now hit three times (see #138, #150).

Side effects

No CSS, no DOM, no rendered output changes, so no visual baselines move — neither the -darwin nor the -linux snapshot set needs refreshing.

In MODE=app, a repeated click on the currently-active link no longer refetches. That is the intended behaviour for these loaders, which are pure functions of pathname and search, but it is a behaviour change and deserves a CHANGELOG line.

One consequence worth naming in the PR rather than discovering later: the search index fetcher calls fetcher.load(withBaseurl('/myst.search.json', baseURL)) at app/components/toolbar/Search.tsx:399-403, and there is no dedicated route for that path in app/routes/, so it matches the $.tsx splat. Adding shouldRevalidate to routes/$ therefore also governs that fetcher's revalidation. The effect looks benign-to-desirable — Search.tsx:397 already carries // TODO: this reloads every time the search box is opened. — but it should be checked, not assumed.

Upstream

The shouldRevalidate exports are a workaround and stay here. The underlying design flaw belongs at jupyter-book/myst-theme: Document's staticBuild branch swaps the Link provider to reloadDocument but leaves a loader-bearing data router hydrated, so the client can still initiate loader fetches that a static host cannot answer. A staticBuild document arguably should not render <Scripts /> with live loaders at all, or should default every route to shouldRevalidate: () => false. This warrants an UPSTREAM-CANDIDATES.yml entry in the same PR, status: pending, per the local-first resolution recorded in #145. I searched jupyter-book/myst-theme and found no existing upstream issue for it.

Note that #28 does not cover this and would not repair it. React Router 7 keeps the same hash-removal semantics in isHashChangeOnly, and a static build would still hydrate a loader-bearing data router, so the fix is needed independently of the migration and must survive it.

Related defect: ↑ Top is a full document reload

Found while measuring this and not previously filed. The back-to-top control resolves to a different pathname from every other in-page link on the same page, so it leaves the document instead of scrolling.

Control Rendered href Document requests on click JS context survives?
Outline entry /python-by-example/#overview none yes — in-page scroll
↑ Top /python-by-example#top GET /python-by-example301GET /python-by-example/200 no — full reload

Both come from the same useLinkProvider() Link with a to="#…" value (app/components/Outline.tsx:26 and :63), which looks contradictory until you look at when each is rendered. The SSR'd HTML contains only href="/python-by-example#top" and zero /python-by-example/# hrefs — the outline list does not exist server-side at all, because useHeaders('main h2', 3) (Outline.tsx:46) reads the headings out of the DOM after mount. So the outline's hrefs are resolved against the hydrated pathname /python-by-example/ (with the trailing slash) while BackToTop was resolved server-side against /python-by-example (without), and the SSR'd attribute survives hydration. Netlify then 301s the shorter form. I confirmed all of this on the deploy, including that a window marker set before clicking ↑ Top is gone afterwards while it survives an outline click.

The cost is a needless full page load — losing scroll position, any Thebe/JupyterLite session state, and about a second — on the single control whose whole purpose is to move within the page. It is also the reason back-to-top happens not to trigger the Back bug on lecture pages, while on the landing page, where the paths do match (/#top), it does; I reproduced that too.

Sphinx gets this right for the obvious reason: its back-to-top is a bare href="#top" with no path component at all, against the same id="top" element the theme already renders. Making ↑ Top a plain <a href="#top"> is one line, matches Sphinx, and is simply correct — a fragment link to the current document never needs client routing or path resolution. It is small enough to fold into this PR, and doing so keeps the anchor-navigation behaviour consistent in one change.

Options

  1. Ship the shouldRevalidate exports plus the static-serve Playwright project plus the ↑ Top href, as one PR, ahead of feat: QuantEcon code-token palette and seoul256 text colours (Phase 3) #171 and feat: language switcher, hreflang, RTL and translator credit (Phases 4–5) #174. Recommended. It is the only functional defect in the review set, it is a hard cutover blocker, it moves no baselines, and the conflict cost is three module-level exports.
  2. Ship the shouldRevalidate exports now and file the static-serve test project as a follow-up. Cheaper this week, but the fix is then unguarded and the next static-only regression finds us the same way Self-hosted stylesheet assets 404 in static builds: url() points at /myst_assets_folder, which myst build --html never creates #138 and Residual #138: route-level stylesheets outside _assets/ still carry absolute /myst_assets_folder asset URLs #150 did.
  3. Wait for Migrate off Remix v1 — target React Router 7, upstream-first (tracks jupyter-book/myst-theme) #28. Not viable — React Router 7 keeps the same hash-removal semantics, so the migration does not repair this, and every lecture page on the cutover deploy stays broken in the meantime.

Decisions needed

  • Predicate shape. The verified guard opts out whenever pathname and search are unchanged and there is no submission. A narrower alternative would opt out only when MODE === 'static', but a module-level shouldRevalidate export has no clean access to loader data and would need a global read. I recommend the broader predicate: it is simpler, and it is semantically correct for these loaders in both modes.
  • Whether the ↑ Top href change rides along or gets its own issue. I recommend folding it in — same subsystem, same file, no baseline impact.

Where it sits

A hard cutover gate: this is not a polish item, it is the one thing in the review set that makes deployed pages unusable. It is deliberately not filed under #92 — the throw happens to surface inside a meta() export, but this is a routing and data-revalidation bug, not a metadata gap — and it is not a duplicate of #28 or of #126 (I measured zero console errors on initial load of both the landing page and a lecture page on this deploy; the errors appear only on the Back navigation).

Conflicts are textual only, and small. #174 edits all three of app/root.tsx, app/routes/$.tsx and app/routes/_index.tsx, and #175 edits app/root.tsx; neither contains the string shouldRevalidate, and #174's routes/$.tsx hunk touches only the getMetaTagsForArticle return, leaving both the if (!data) guard and the throwing line alone. Whichever lands second takes a trivial rebase. No binary conflicts with #171 or #174, since no baselines move.

Next action: open a PR with the three shouldRevalidate exports, the static-serve Playwright project and its click-anchor-then-Back assertion, the ↑ Top href change, a CHANGELOG entry, and the UPSTREAM-CANDIDATES.yml row for the myst-theme design flaw.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is wrong or broken in a lecture or buildhigh-priorityAddress soonjavascriptPull requests that update javascript codeready

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions