Skip to content

fix(browser): read the canonical against the cache key, not the document (v1.17.0) - #90

Closed
harper-joseph wants to merge 3 commits into
mainfrom
fix/canonical-variant
Closed

fix(browser): read the canonical against the cache key, not the document (v1.17.0)#90
harper-joseph wants to merge 3 commits into
mainfrom
fix/canonical-variant

Conversation

@harper-joseph

@harper-josephharper-joseph commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes the one duplicate-target class in #84 that nothing upstream could catch.

What #84's encoding variants actually do, measured

Probed a live faceted origin directly (bypass token, 100+ requests). Every re-spelling of one
catalog facet URL returns the same page — same product set, same title — and declares a
<link rel="canonical"> pointing at the sitemap spelling:

variant of the sitemap URLorigincanonicalour cache keycaught before this PR
control (sitemap loc)38 productsself
facet values reordered38 products→ controldifferentcanonical-mismatch
wrong / truncated slug38 products→ controldifferentcanonical-mismatch
separators as %2B38 products→ controldifferentcanonical-mismatch
separators as %252B (double-encoded)0 products, junk page→ a fabricated junk URLdifferentcanonical-mismatch
separators as %2038 products→ controldifferentslipped through

So four of the five classes were already self-limiting: suppressed on first render, rechecked
at render.suppression.recheckInterval (7d), deleted after maxStrikes (4) ≈ 28 days. That is
~0.18 renders/day per junk URL, not the "~2×/day forever" #84 assumes — I'll correct the issue
body separately.

The %20 case could not be caught: normalizeCanonicalUrl does a URLSearchParams round-trip
that collapses %20 and + on both sides, so the page read as self-canonical while
canonicalizeUrl (correctly) kept the two spellings as different keys. That variant became a
full-privilege recurring target holding bytes identical to its twin, forever.

The fix

Split along what is invariable vs what is a property of the site. This plugin serves websites
of all types, so the half that depends on how one origin parses its query is config, not a
hardcoded assumption:

configurable?
canonical names a different document → non-indexable (canonical-mismatch)no — invariable, every site
canonical names this document re-spelled as another cache key → duplicate (canonical-variant)yescanonical.strict, default false

strict: false reproduces today's lenient reading byte-for-byte, so upgrading changes nothing
until a deployment opts in. Whether two spellings are one resource is decided by the origin's
query parser — a form-decoding origin cannot tell + from %20, an RFC-3986 one can — and that
is settled by one request per allowlisted parameter, for every URL: ask for a value with a
literal plus (?f=A%2BB), then the same value with a raw + (?f=A+B); if the origin resolves
the second as a space, it form-decodes and the two spellings can never name different resources.
The discriminator is in the CanonicalConfig doc comment so an operator can decide without a crawl.

Take the verdict on canonicalizeUrl — the function the cache key is actually built with.
The consequence of a canonical verdict is Target.suppress, and a Target is a cache key, so
"does this name the same key" is the question worth asking, not "does this name the same
document". canonicalAllowsIndexcanonicalVerdict returning self | variant | elsewhere:

  • variant — same document re-spelled as a different key → new reason canonical-variant,
    so a wave of duplicate spellings reads differently from an origin disowning its own pages.
  • elsewhere — unchanged behaviour, still canonical-mismatch.
  • The rendered URL's own param names are the allowlist for both sides, so a param the route
    drops can never manufacture a mismatch.

Why this is not fixed in the cache key

+ and %2B are interchangeable only in separator position. In value position they are
different values — measured on the live origin, Brand:ACME%2BCO returns that brand's 52
products while Brand:ACME+CO returns an empty page canonicalizing elsewhere. Folding them in
canonicalizeUrl would serve one facet's HTML under another facet's URL (the canonical half is
also the origin-fetch/navigation URL), and re-keying orphans every cached page in the corpus.

Safety

The failure mode worth worrying about is an origin whose canonicals are spelled differently
from its own sitemap (writing + for spaces is common): every page would be flagged, and
Target.suppress deletes the cached page immediately and the target after 4 strikes.

That cannot happen to a declared corpus, on any deployment. A sitemap-listed URL is
serialized even when non-indexable, so its result posts with content and rendered wins in
RenderJob.outcome — the plugin's suppression branch is never reached. Only URLs the plugin
discovered are retirable by a canonical verdict, which is exactly #84's subject. A new test
pins that guarantee so a refactor can't quietly remove it. For such an origin the only visible
effect on the declared corpus is isIndexable: false on the stored page (a debug header and an
admin column — it does not gate serving).

Deployment-specific evidence: 103/103 real sitemap locs are self-canonical under the strict
comparison — 60 catalog facet URLs sampled across 3 catalog sitemaps, 30 PDPs across 2 product
sitemaps, the homepage, and 12 re-probed with a mobile UA (no per-device canonical divergence).
Zero would-be suppressions.

Tests

npm run lint, format:check, browser 117 pass, plugin 642 pass. New: the %20 re-spelling
verdict, the %2B-in-a-value regression guard (must stay indexable), route-dropped params
can't mismatch, plus two renderer-level integration tests (canonical-variant reason; the
sitemap blast-radius guarantee).

The plugin change is comment-only — the reason string flows through as data — so no plugin
release is needed.

Refs #84

Measured in production

The existing mechanism is live and busy. Suppression reasons from the four nodes' hdb.log,
24h to 2026-08-12T22:52Z (two lines per URL — desktop + mobile):

reason24h, cluster-wide
canonical-mismatch13,036
noindex13,120
http-error2,101

So ~6.5k distinct URLs/day already get a canonical verdict. On one node, 85% of the distinct
canonical-mismatch URLs are /product/prd-N/product.jsp — a placeholder slug, the class that
was already caught.

The class this PR adds, sized. A 30-minute read_audit_log window on render_service.Target
(615 distinct discovery-created URLs, 93% catalog — consistent with the ~27k/day in #84) contains
27 URLs carrying the space-written-as-+ signature: a multi-word facet value spelled
Silhouette:Bath+Rugs where the canonical spells Bath%20Rugs. Probing all 27 against the origin:

count
newly caught by this PR (self-canonical under the old comparison ⇒ permanent duplicate target)19
already suppressed today (elsewhere)6
declare no canonical at all — invisible to any canonical logic2

That extrapolates to ~1,300 such URLs/day, ~900/day of them newly retired. Each was a recurring
target on two device keys at the 24h floor — ~2 renders/day, forever, for bytes the canonical
spelling already describes.

Note the direction: production's real shape is + written where the canonical has %20, the
mirror of the fixture in url.test.ts. Same equivalence class, same collapse — the fixture is
just written the other way round.

End-to-end confirmation on one pair, via POST /prerender_admin/explain: the discovered +
spelling is a live recurring target (scheduled: true, suppressed: false); its %20 canonical
twin has no target at all.

Which is worth stating plainly: suppression does not hand coverage to the twin. Only 5 of the 25
differing canonicals are in a sitemap, so for most of them neither spelling ends up prerendered
and bots get the origin proxy for that page. That is the correct outcome — the origin disowns the
+ spelling and Google folds it into the canonical regardless — but "adopt the canonical as a
new target" is deliberately not part of this PR: the origin fabricates junk canonicals from
junk input (see #84), so adoption amplifies rather than heals.

All production access for this was read-only: log greps over SSH, one read_audit_log, and
explain / overview reads. The origin probes carry the bypass token, so they go straight to
the origin and never reach the plugin — no targets were created by measuring.

@gemini-code-assistgemini-code-assistBot 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 new 'canonical-variant' verdict to identify and suppress duplicate cache keys generated by re-spelled canonical URLs (such as differences between '%20' and '+'). It replaces the 'canonicalAllowsIndex' helper with 'canonicalVerdict' to categorize canonical URLs as 'self', 'variant', or 'elsewhere'. Feedback suggests a minor optimization in 'packages/browser/src/util/url.ts' to access 'current.searchParams' directly instead of redundantly instantiating a new 'URLSearchParams' object.

Comment threadpackages/browser/src/util/url.ts Outdated
return normalizeCanonicalUrl(new URL(canonicalHref, currentUrl)) === normalizeCanonicalUrl(currentUrl);
const current = new URL(currentUrl);
const canonical = new URL(canonicalHref, current);
const allowlist = [...new URLSearchParams(current.search).keys()];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since current is already a URL instance, you can directly access its searchParams property instead of redundantly instantiating a new URLSearchParams object from current.search. This avoids unnecessary parsing and object allocation.

Suggested change
constallowlist=[...newURLSearchParams(current.search).keys()];
constallowlist=[...current.searchParams.keys()];

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Applied — current is already a URL, so that re-parsed a query string parsed a line earlier. Now current.searchParams.keys(). This code has moved to #94, which now carries the whole URL/normalization change end to end.

…ent; v1.17.0
A faceted origin resolves `+`, `%2B` and `%20` between facet values to one page and
canonicalizes all of them to the `+` spelling — but each spelling is its own cache key.
The self-canonical test ran on `normalizeCanonicalUrl`, whose form-encoding round-trip
collapses `%20` and `+`, so a `%20`-for-`+` re-spelling read as self-canonical and became
a second recurring target rendering the same bytes ~2x/day forever. Every other
re-spelling in that family was already caught; this one could not be, by construction.
Take the verdict on `canonicalizeUrl` instead. The consequence of a canonical verdict is
`Target.suppress`, and a Target IS a cache key, so "same key" is the question worth asking,
and asking it through the key-building function means the answer cannot drift from what the
plugin stores. `canonicalAllowsIndex` becomes `canonicalVerdict` returning
self | variant | elsewhere; 'variant' posts the new reason `canonical-variant` so a wave of
duplicate spellings stays legible next to genuine mismatches. The rendered url's own param
names are the allowlist for both sides, so a param the route drops can never manufacture a
mismatch.
NOT solved in the cache key on purpose: `+` and `%2B` are interchangeable only in separator
position. In value position they are different values (measured against a live faceted
origin: `Brand:ACME%2BCO` returns the brand's 52 products, `Brand:ACME+CO` returns an empty
page canonicalizing elsewhere), so folding them would serve one facet's HTML under another
facet's url — and re-keying the corpus orphans every cached page.
Blast radius, verified in code and pinned by a new test: a sitemap-listed url is serialized
even when non-indexable, so its result posts with content and `rendered` wins the outcome —
the declared corpus cannot be retired by any canonical verdict, only discovered urls can.
Measured on a live faceted origin: 103/103 real sitemap locs (60 catalog facet urls from 3
sitemaps, 30 PDPs, the homepage, 12 re-probed with a mobile UA) are self-canonical under the
strict comparison, i.e. zero would-be suppressions.
Plugin change is comment-only (the reason string flows through as data) — no release needed.
Refs #84
… config
This plugin serves websites of all types, so a rule that depends on how one origin
parses its query cannot be hardcoded. Split the verdict along that line:
- A canonical naming a DIFFERENT document disowns the page. Invariable, every site,
not configurable.
- A canonical naming this very document RE-SPELLED as another cache key ('variant')
is a duplicate only if that origin's parser cannot tell the spellings apart —
a form-decoding origin cannot, an RFC-3986 one can. So: `canonical.strict`,
default false, which reproduces the historical lenient reading byte-for-byte.
The doc comment carries the one-request discriminator that settles it for a given
parameter across every URL (ask for `?f=A%2BB`, then `?f=A+B`; if the origin resolves
the second as a space it form-decodes), so an operator can decide without a crawl.
Tests pin both directions plus the invariable half under either setting.
…s query
From review on #90 — `current` is already a URL, so `new URLSearchParams(current.search)`
re-parsed a query string that was parsed a line earlier.
@harper-joseph

Copy link
Copy Markdown
ContributorAuthor

Superseded by #94, which now carries the whole URL/normalization change end to end — this commit is merged into that branch unchanged, plus cacheKey.trailingSlash (#93) and cacheKey.plusIsSpace (#92), so the invariable rules and the site-contingent ones land as one coherent policy instead of three releases. The review comment here is addressed there.

Sign up for freeto 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

@harper-joseph