Skip to content

fix(browser): CSS url() self-references are fragment-only, and a canonical verdict reports the canonical it declared; v1.40.0 - #239

Merged
harper-joseph merged 4 commits into
mainfrom
fix/self-ref-fragments
Oct 2, 2026
Merged

harper-joseph merged 4 commits into
mainfrom
fix/self-ref-fragments

Conversation

@harper-joseph

@harper-joseph harper-joseph commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Two changes, both for serving one entity's render at its other spellings (#237):

  1. The serialized snapshot rewrites every CSS url(<this page's URL>#id) to url(#id), so a reference a script built from location still points into the same document when the snapshot is served at a URL other than the one it was rendered at. This is the "known difference to fix first".
  2. A non-indexable render whose canonical names another URL posts that canonical as declaredCanonical. The plugin's entity registry records it (#243): after a re-slug, the render of the old spelling is often the first fetch from the origin to see the new canonical, and the result used to carry only the verdict.

Why

  • A snapshot is already served at more than one URL: every query-string variant of a route whose key drops the query (queryParams: []). With the plugin's entity serve (#237) it is also served at every other spelling of the same entity.
  • On one production origin, the only location-derived references in two examined product snapshots came from a third-party reviews widget. It fills its rating stars with fill="url(<page URL>#rating_star_filled)" (observed). Every product link in those snapshots was the canonical, and there were no share links.
  • At any other URL that absolute reference names a different document, so it becomes an external paint server. Measured with Chrome 148, serving one document at two same-origin paths, with this page's fill pointing at the first:
Reference, page served at the second path Paint
url(<absolute first URL>#id) no fill
url(<first path>#id) (path-absolute) no fill
url(#id) filled
absolute, with a fallback colour the fallback colour
  • At the first path every form paints. At the second, Chrome also fetched the first URL trying to resolve the paint server, so an un-rewritten snapshot served at another URL costs the crawler's renderer an extra document request per view, and still paints nothing.

What

  • src/selfReferences.ts (new): fragmentOnlySelfReferences(html, pageUrl) matches a CSS url( in any case, with optional whitespace and an optional ", ' or &quot; quote, followed by this page's URL and #. The URL can be absolute, scheme-relative or path-absolute, and is matched exactly with its query, both raw and with & serialized as &amp; inside attributes.
  • Left alone: references to other URLs, this URL without a fragment, and every href. An href resolves against <base>, so a fragment-only href is not the same reference.
  • src/renderer.ts: applied to the post-processed HTML using page.url(), inside the timed postProcess phase.
  • It is always on, not an option, because it is equivalent at the render URL. A fragment-only url(#id) refers to the current document whatever its URL and whatever <base> says (CSS Values 4, fragment-only URLs).
  • Cost: a page with no such reference pays two substring scans of the serialized document, and the regex runs only on a page that has one.
  • README.md: a section under the post-processing docs.

declaredCanonical

  • src/renderer.ts: indexVerdict returns declaredCanonical for a CANONICAL verdict only (canonical-mismatch, or a strict canonical-variant), never for a noindex page, whose canonical is not something to adopt a target from. It is resolved against the rendered URL exactly as canonicalVerdict resolved it to reach the verdict, with the fragment dropped, and only for an http(s) URL of at most 2,048 characters (past ~1,978 bytes no Harper key could hold it). Both verdict paths, the pre-settle bail and the post-settle check, assign it on every verdict, so an earlier attempt's never outlives a later one's.
  • src/RenderJob.ts: posted with or without content. A sitemap target is serialized even when its canonical names another URL, and that re-slugged listed URL is exactly the one whose new canonical the plugin wants. It is absent, not null, when there is nothing to say, so an older plugin never sees the key. src/renderOnce.ts surfaces it too.

Verification

  • test/selfReferences.test.ts: every quoting the serializer produces, the scheme-relative and path-absolute spellings, the query (raw and &amp;), a hash on the page URL, and the cases that must not change (another document, a prefix-sharing path, another scheme, href, <use href>, plain text).
  • An end-to-end renderOnce of a page whose script builds fill="url(" + location.href + "#star_filled)": the stored HTML carries url(#star_filled) and no absolute self-reference. With the rewrite disabled, that test fails.
  • Paint (91ebecc): the rendered snapshot, served at another URL, is screenshotted and its star pixel read. It is filled. A control with the absolute reference restored reads unfilled, so the test can see the defect.
  • declaredCanonical:
    • test/earlySettleSkip.test.ts:
      • indexVerdict resolves an absolute or a path-absolute canonical, drops a fragment, and reports a strict variant.
      • It reports nothing for a noindex page (whatever its canonical), a non-web scheme, an unkeyable length, an indexable page, or no canonical.
      • End to end, both the bail and the post-settle verdict report it, a sitemap target that is captured anyway reports it, and an indexable render does not.
    • test/jobResult.test.ts: it is on the wire for a canonical verdict without content, and for a sitemap target's stored render with content, and absent from an indexable render.
    • Review: the fresh-context review of #243 flagged that noindex pages, any scheme and any length were posted. The live-render agent flagged that a stored sitemap render posted none. Both are fixed in a4f96b7.
  • Browser suite 428/428, lint and format clean.

Render parity

Stored HTML changes only on pages that carry an absolute or path-absolute url(<self>#…). On those, the only bytes that change are the URL inside each such reference. structuredOffers, pageFacts, outcome, status and indexability are read before this step and are unaffected.

Verified on live product pages. A fresh-context agent ran the deployed render config through render-service's render-once against one production origin, 2026-10-02. It rendered three product pages with a reviews widget, on desktop and mobile, with the released 1.38.0 and with this build, back to back:

  • Diff: 0 unexpected hunks. Of 4,118 tag-level hunks across 8 document pairs:
    • 280 are the self-reference rewrite.
    • The rest is ordinary render-to-render churn: recommendation ids, review counts and a widget's gradient counter, reorders, a payment banner present or absent, CSS rule order, and a swatch preload. Two 1.38.0 renders of the same page differ in the same ways.
    • Applied directly to the 1.38.0 bytes, the rewrite made exactly 50 edits per document, each removing only the page-URL prefix.
  • References: url(# rose by 50 per document. No url( naming the page's own URL or path with a fragment remains.
  • Result fields: outcome, status, isIndexable, reason, redirectedTo, structuredOffers and pageFacts are JSON-equal on all 8 pairs.
  • Paint, with each snapshot served at another path and every external request aborted:
    • 1.38.0: 0 of 45 stars filled.
    • 1.40.0: the same per-star fill as 1.38.0 at its own URL. The unfilled ones are genuinely empty rating stars.
    • 1.40.0 at its own URL: identical again, so the rewrite is a no-op there.
  • declaredCanonical:
    • A misspelled product URL, rendered as production renders it, is canonical-mismatch, and 1.40.0 reports the product's real canonical, which renders indexable. 1.38.0 has no such key.
    • The live check ran before a4f96b7. It found that a non-indexable render which still stores content (a sitemap target) posted no declaredCanonical. It now does. Its stored pageFacts.canonical carries the same URL as well, and the plugin reads either (#243).

Not in this PR

  • Snapshots rendered before this release keep their absolute references until their next render, up to one render interval.
  • <use href="<self>#id"> and other href self-references are not rewritten (see above).

Release: browser 1.40.0 (1.39.0 is reserved by #228). No config change. render-service picks it up with its normal tarball bump after the release.

🤖 Generated with Claude Code

…shot is correct at every URL it is served at; v1.40.0

A snapshot is served at more URLs than the one it was rendered at: every
query-string variant of a route whose key drops the query, and (plugin
entityServe, #237) every other spelling of the same entity. A script-built
`fill="url(<page URL>#id)"` (a reviews widget's star fills) names a
different document at any of those URLs, so the reference becomes
external.

The serialized snapshot now rewrites CSS `url(<this page's URL>#id)` --
absolute, scheme-relative or path-absolute, any quoting, raw or with `&`
serialized as `&amp;` -- to `url(#id)`. A fragment-only url() always
refers to the current document whatever its URL or <base>, so the rewrite
is equivalent at the render URL. hrefs are left alone (they resolve
against <base>). A page without such a reference pays two substring scans.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a mechanism to rewrite absolute self-references in serialized snapshots to fragment-only URLs (e.g., converting url(#id) to url(#id)), ensuring CSS references remain valid when snapshots are served at different URLs. The changes include the core rewriting logic in selfReferences.ts, integration into the renderer's post-processing step, updated documentation, and comprehensive tests. I have no feedback to provide as the implementation is robust and well-tested.

trial and others added 2 commits October 2, 2026 12:38
…ther URL the rewritten stars fill and the absolute ones do not; v1.40.0

The PR claimed an absolute self-reference loses its fill at another URL
as a hypothesis. Measured with Chrome 148: at a second same-origin URL,
`url(<render URL>#id)` and the path-absolute spelling paint nothing (an
external paint server), the browser fetches the render URL trying to
resolve it, and `url(#id)` paints. The new test renders a page, serves
the snapshot at another URL and reads the pixel; a control with the
absolute reference restored must read unfilled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…red (declaredCanonical), so the plugin learns where the product lives now; v1.40.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@harper-joseph harper-joseph changed the title fix(browser): make CSS url() self-references fragment-only, so a snapshot is correct at every URL it is served at; v1.40.0 fix(browser): CSS url() self-references are fragment-only, and a canonical verdict reports the canonical it declared; v1.40.0 Oct 2, 2026
… canonical verdict, posts with a stored sitemap render too, http(s) and keyable only, reset on every verdict; v1.40.0

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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