Repository navigation
feat(browser): post the page's structured Product offers with the result; v1.20.0 - #140
Conversation
…ult; v1.20.0 The consumer needs to know what a rendered page CLAIMS about price and availability, to compare against the origin. It can recover that by regex-scanning and JSON-parsing the serialized document, but that is ~1MB of work on its hottest write path to reconstruct data this process had structured in front of it. So read it here instead: one page.evaluate against the settled DOM, before postProcess (which may strip nodes), flattened to [price, currency, availability] triples and sorted so unchanged content produces an identical sequence. Capped at 200 triples so a pathological page cannot turn a small metadata field into a large one, and best-effort — a failure never costs the render its content. Only for pages that will be stored (indexable or sitemap-listed), and null rather than an empty array when the page declares no Product offers, so "no structured data" stays distinguishable from "no offers". Consumed by @harperfast/prerender >= 0.58.0 (pageCheck), which falls back to parsing the HTML for results from older renderers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request bumps the package version to 1.20.0 and introduces the extraction of schema.org Product offers (structuredOffers) directly from the settled DOM during rendering, which avoids expensive parsing downstream. The extracted offers are flattened into [price, currency, availability] triples, capped at 200, and sorted. Feedback suggests improving the robustness of the availability URL parsing to handle trailing slashes and optimizing the sorting logic to avoid redundant JSON.stringify calls.
| for (const entry of list) { | ||
| if (!entry || typeof entry !== 'object') continue; | ||
| const offer = entry as Record<string, unknown>; | ||
| const availability = typeof offer.availability === 'string' ? offer.availability.split('/').pop()! : null; |
There was a problem hiding this comment.
If offer.availability is a URL with a trailing slash (e.g., https://schema.org/InStock/), split('/').pop() will return an empty string "" instead of "InStock". Filtering out empty segments before popping ensures robust extraction of the last path segment.
| const availability = typeof offer.availability === 'string' ? offer.availability.split('/').pop()! : null; | |
| const availability = typeof offer.availability === 'string' ? offer.availability.split('/').filter(Boolean).pop() ?? null : null; |
| for (const node of nodes) collect(node); | ||
| }); | ||
| if (!triples.length) return null; | ||
| triples.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); |
There was a problem hiding this comment.
Sorting the triples by calling JSON.stringify on every comparison is inefficient, as it results in O(N log N) stringifications. For up to 200 triples, this can lead to thousands of redundant stringifications. Comparing the elements of the triples directly is much more efficient and avoids any stringification overhead.
triples.sort((a, b) => {
for (let i = 0; i < 3; i++) {
const valA = a[i];
const valB = b[i];
if (valA === valB) continue;
if (valA === null) return -1;
if (valB === null) return 1;
return valA.localeCompare(valB);
}
return 0;
});…t (review)
Two findings from review:
- availability.split('/').pop() returns '' for a URL with a trailing
slash (https://schema.org/InStock/), which would read as no
availability at all. filter(Boolean) before pop.
- sorting with JSON.stringify inside the comparator serialises every
triple O(n log n) times. Compare field-wise instead.
Adds a regression test for the trailing-slash case.
|
Both findings taken — fixed and pushed. Trailing-slash availability:
151/151 browser tests. |
…, null means empty Self-review pass. The call site collapsed the extraction's null into undefined, so a page with no Product offers was OMITTED from the payload — and the consumer reads an absent field as 'this renderer predates structuredOffers' and raises its hourly upgrade alarm. Post null as null: on the wire, null = extraction ran and found nothing (or failed benignly), absent = renderer too old. Also swap the sort comparator's localeCompare for code-unit comparison — the sort exists to make the sequence identical across renders, and collation varies with the browser's locale/ICU. Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
…ross nodes, and bounds bytes Adversarial review findings. (1) The cap was enforced inside one offers array and between script blocks, but not between nodes of one @graph — every Product node past the cap still pushed a triple, so one block with 10k single-offer nodes posted 10k triples with every result. (2) The cap bounded count, never bytes: a single megabyte price string rode through whole. Fields are now sliced at 64 chars — no legitimate price, currency, or availability token comes near that. (3) Worse than either: past the cap the old code posted a TRUNCATED DOM-order sample, and a deterministic sample that omits the offer the consumer's endpoint reports disagrees with it on every pass — the consumer would expire and re-render such a page forever. Overflow now posts null: too many offers to read confidently degrades to exactly what no offers does. Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
|
Second self-review round plus a fresh-context adversarial pass (coverage details in the body). Two commits since the last review:
153/153 tests against real headless Chrome. Body rewritten to current state. |
Companion to prerender-plugin#139 (
pageCheck), and a direct response to review feedback on it: don't make the plugin decompress and parse the whole document to recover something the renderer already has in a live DOM.Why
#139 needs to know what a rendered page CLAIMS about price and availability, so the probe can ask "does the cached page still agree with the origin" — the question that catches a value which changed and changed back between two probes (measured at ~2.7% of served product pages on one deployment).
The plugin could recover those values from the stored HTML, but that means a regex scan plus a JSON parse of a ~1MB commerce PDP on the hottest write path in the plugin, to reconstruct data this process had structured in front of it. Doing it here is one
page.evaluateagainst a DOM that is already parsed and already settled.What
RenderJob.structuredOffers— the page's schema.org Product offers, flattened to[price, currency, availability]triples, posted in the result metadata.postProcess, which may strip nodes: the field must describe the page as rendered.localeComparewould have made the order depend on the browser's locale/ICU).https://schema.org/InStock→InStock), matching the plugin's extraction contract exactly, including trailing slashes.null— no claim, never a truncated one. A deterministic DOM-order sample that omits the offer the consumer's endpoint reports would disagree with it on every comparison, and a systematic disagreement means the consumer expires and re-renders that page forever. The cap holds across@graphnodes and script blocks (it originally leaked one triple per Product node past the cap — an unbounded array from a single crafted block), and every field is byte-bounded at 64 chars (the cap bounds count; without this one megabyte-longpricestring still inflated every posted result).nullis posted asnulland means the extraction ran and the page declared no Product offers (or it failed benignly); an absent field means the renderer predates this feature — which is the case the consumer alarms on hourly. Collapsingnullintoundefinedwould make every offerless page impersonate an outdated renderer.structuredOffersfield genuinely is an old renderer.Compatibility
Additive metadata field, ignored by consumers that don't read it (including plugin <= 0.57.0 — deploy order between the two packages does not matter).
@harperfast/prerender>= 0.58.0 requires it forpageCheck: there is no fallback to parsing the HTML on that side, sopageCheckis inert until this ships, and logs hourly to say so. Neither package blocks the other on deploy; the feature simply does nothing until both are in place.Verification
renderOnceharness): flattening and sort order,@graphdocuments plus a single non-array offer, one malformed JSON-LD block not costing the page its other blocks, a no-offers page postingnull(present on the wire, distinct from absent), a trailing-slash availability URL still yielding its verdict, a 250-Product@graphpostingnullrather than a truncated sample, and a 100k-char price being sliced to 64.tscbuilds.For the human reviewer
page.evaluatefunction (no imports, no module-scope closure), matchingextractIndexSignalsbeside it.postProcess. IfpostProcessever gains the ability to rewrite structured data, this ordering is what keeps the field honest about what was stored.pageCheckcoverage (posts no claim), which is the deliberate trade against ever posting a claim built from a sample.Review coverage
Authored by Claude (Fable). Two self-review rounds plus a fresh-context Claude adversarial pass over both PRs' diffs with full repo access (it found and executably reproduced the cap leak and the byte-unbounded field, both fixed here). Outside-model legs were attempted and are unavailable on this machine (Codex CLI: burned refresh token, needs
codex logout && codex login; Gemini CLI: no API key configured), so model coverage is Claude-only and degraded, flagged per policy.🤖 Generated with Claude Code