Skip to content

several fixes related to hash urls - #139

Merged
velzie merged 4 commits into
developfrom
fix/hash-spec
Oct 3, 2026
Merged

velzie merged 4 commits into
developfrom
fix/hash-spec

Conversation

@velzie

@velzie velzie commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

velzie and others added 4 commits September 29, 2026 17:52
The scramjet harness, its wisp server and the bare harness were fixed at
4500-4502, so two runway instances (or anything else on those ports)
could not run side by side. RUNWAY_PORT_BASE moves all three, and the
harness page finds wisp next to its own port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb
…s in the document

The fragment went through the URL codec, so the real URL's fragment was
not the site's: `#a/b` became `#a%2Fb`, which broke `:target`, native
scroll-to-anchor, text fragments and `location.hash` round trips. It is
never sent anywhere, so it now stays as written. The JS rewriter
encoded it into static import URLs too, so `import "./m.js#a"` and
`import("./m.js#a")` could load two instances.

In-page `#x` navigations reloaded the document: rewritten URLs carry
the navigator's query params (`$io`, ...) and the document's real URL
carries its loader's, so the browser never saw the same document. A
navigation to the document's own URL (links, location setters, assign,
replace, `window.open(url, "_self")`, meta refresh, controller.go) now
goes to its real URL with the new fragment, and the browser applies its
own fragment-navigation and replace rules. Fragment-only hyperlinks are
left relative, so they resolve when followed - after pushState too.

That relies on the real <base href> being a proxy URL, which it was
not: anything left relative resolved against the real site. It is now
rewritten (ignored `data:`/`javascript:` bases are left for the browser
to ignore). The client's base URL resolved a relative base against the
origin rather than the document URL; only the first base element with
an href counts; about:blank and srcdoc documents fall back to their
creator's base URL.

History: pushState/replaceState implement "can have its URL
rewritten" rather than comparing origins, resolve against the base URL,
treat "" like null as Chrome does, and keep the document's own request
params on the real entry URL, so a frame reloads as a frame.

Also: CSS `url(#id)` is left alone (gradients, clip paths, masks,
filters), blob URLs keep their fragment (`#t=5`, `#page=2`), window
handler content attributes (onhashchange, onpopstate, onmessage, ...)
are rewritten instead of running against the real globals, and
`_top`/`_parent` target keywords are matched case-insensitively.

Tests: runway fragments.ts (end to end against bare Chrome, reached
cross-origin the way real pages are) and rewriter-fragments.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb
…n, file: history

- A fragment put back into a static import's string literal is escaped
  for one. The fragment percent-encode set leaves `\` and `'`, so
  `import "./m.js#a\\"` came out as invalid JavaScript. The module's own
  URL had the same problem in `$scramjet$meta(import.meta, "...")` and
  `$scramjet$import("...", ...)`: the service worker sees the fragment a
  module was requested with, and the rewriter put it in unescaped. Both
  now go through one escape function, and Flags carries the escaped base.

- Only an HTML base element in the document's own tree sets the base URL.
  The service worker adopted the first `<base>` it saw, including one in
  a <template> or an SVG/MathML element named base, so later scripts
  loaded from the wrong directory. The client matched `base[href]`, which
  also finds the SVG one; it now checks the namespace.

- A navigation to the document's own URL only reuses its real URL when it
  has a fragment - when it stays in the document. Without one it loads a
  new document, and reusing the real URL kept the initiator of whoever
  first loaded it (`$io`), so `location.assign(location.href)` after
  entering from another origin was sent as Sec-Fetch-Site: same-site. The
  cost is that such a navigation pushes a history entry where the browser
  replaces one (fragment-self-navigation-replaces-entry, listed failing).
  A <base> naming the document still resolves to its real URL, since it
  loads nothing itself.

- "can have its URL rewritten" lets a file: URL change its query, as HTML
  says; it fell through to the fragment-only rule. Moved out of the
  history interceptor so it can be tested on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb
Whether a <base> sets the document's base URL depends on it being an
HTML element, and the rewriter guessed that from ancestor names. The
parser's own foreign-content tracking made the same guess: `title`,
`mi` and `annotation-xml` counted as integration points in any
namespace, `annotation-xml`'s `encoding` was never read, and breakout
start tags (`<p>`, `<div>`, `<font color>` ... inside <svg>) did not
end foreign content. So `<math><annotation-xml><base>` set the base
URL, and so did a base in an HTML-looking spot that Chrome puts in SVG;
an SVG element named `template` was taken for an inert HTML one.

The parser now decides each element's namespace by the tree
construction rules - MathML text and HTML integration points,
`annotation-xml` by its encoding, breakout tags including `font` with
color/face/size - and DomBuilder records it on the Element. The
tokenizer's raw-text, CDATA and self-closing decisions read the same
state, and only an HTML element is void (an SVG `base` has children).
The base check is now `namespace === "html"` outside an HTML template.

Parser tests pin namespaces to the trees Chrome builds for the same
markup; runway covers the base cases both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb
@velzie
velzie merged commit 2e55e7d into develop Oct 3, 2026
5 of 6 checks passed
@velzie
velzie deleted the fix/hash-spec branch October 3, 2026 19:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant