diff --git a/.changeset/range-replacement-fixes.md b/.changeset/range-replacement-fixes.md
new file mode 100644
index 00000000..444e6cae
--- /dev/null
+++ b/.changeset/range-replacement-fixes.md
@@ -0,0 +1,10 @@
+---
+"@monetizationos/proxy": patch
+---
+
+Range replacement fixes:
+
+- Uses less CPU on pages with many void elements (`img`, `br`, `meta`, ...). Output is unchanged.
+- A range container with no end tag (`img`, or a self-closing SVG element such as ` `) is left as it is instead of failing the whole page transform.
+- A range starts at the first start marker, so a start marker nested inside it no longer inserts the replacement a second time.
+- A range applies to the outermost matching container. A container nested inside another one is part of the outer container's cut.
diff --git a/src/stages/buildReplacementHandlers.ts b/src/stages/buildReplacementHandlers.ts
index 2acd9a26..9983fcb7 100644
--- a/src/stages/buildReplacementHandlers.ts
+++ b/src/stages/buildReplacementHandlers.ts
@@ -3,9 +3,11 @@ import type { HtmlStageContext } from '../context'
import type { SubSurfaceBehaviorApi } from '../types'
import { renderElement } from './elements'
import type { ComponentRange } from './findMarkerPositions'
+import { isVoidElement } from './voidElements'
type ReplacementState = {
- operation: 'SCANNING' | 'REMOVING' | 'REPLACED'
+ // `IN_START`: a start marker nested in the first one doesn't start the range again
+ operation: 'SCANNING' | 'IN_START' | 'REMOVING' | 'REPLACED'
}
export type ReplacementHandler = { selector: string; handler: ElementHandlers }
@@ -69,7 +71,8 @@ export const buildReplacementHandlers = (
const [content, options] = renderElement(ctx, after)
element.after(content, options)
}
- if (element.onEndTag) {
+ state.operation = 'IN_START'
+ if (element.onEndTag && !isVoidElement(element)) {
try {
element.onEndTag(() => {
state.operation = 'REMOVING'
@@ -99,27 +102,47 @@ export const buildReplacementHandlers = (
}
}
+ // Open containers; a container inside another one is part of the outer one's range
+ let depth = 0
+
const parentHandler: ElementHandlers = {
element(element: RewriterElement) {
+ if (depth > 0) {
+ return
+ }
rangeIndex++
- if (element.removed) {
+ // No content to cut
+ if (isVoidElement(element)) {
return
}
+ // A removed container still counts towards `depth`, so the ranges line up with the marker pass
+ const state: ReplacementState | null = element.removed ? null : { operation: modification.fromMarker ? 'SCANNING' : 'REMOVING' }
+ if (state) {
+ states.push(state)
+ }
- const state: ReplacementState = { operation: modification.fromMarker ? 'SCANNING' : 'REMOVING' }
- states.push(state)
-
- if (state.operation === 'REMOVING') {
- for (const prepend of (modification.replaceWith ?? []).slice().reverse()) {
- const [content, options] = renderElement(ctx, prepend)
- element.prepend(content, options)
+ try {
+ if (state?.operation === 'REMOVING') {
+ for (const prepend of (modification.replaceWith ?? []).slice().reverse()) {
+ const [content, options] = renderElement(ctx, prepend)
+ element.prepend(content, options)
+ }
}
- }
- if (element.onEndTag) {
- element.onEndTag(() => {
+ if (element.onEndTag) {
+ element.onEndTag(() => {
+ depth--
+ if (state) {
+ states.pop()
+ }
+ })
+ depth++
+ }
+ } catch {
+ // `onEndTag` throws on a self-closing foreign container (` `), which has no end tag or content to cut
+ if (state) {
states.pop()
- })
+ }
}
},
diff --git a/src/stages/findMarkerPositions.ts b/src/stages/findMarkerPositions.ts
index f64e933b..a06347fb 100644
--- a/src/stages/findMarkerPositions.ts
+++ b/src/stages/findMarkerPositions.ts
@@ -1,6 +1,7 @@
import type { ElementHandlers, HtmlRewriterAdapter, RewriterElement } from '../adapters/HtmlRewriterAdapter'
import type { HtmlStageContext } from '../context'
import type { SurfaceDecisionResponse } from '../types'
+import { isVoidElement } from './voidElements'
export type SurfaceMarkers = {
componentsWithInvalidSelectors: string[]
@@ -52,15 +53,30 @@ export const findMarkerPositions = async (
const { toMarker, fromMarker } = modification
let elementCounter = 0
let currentRange: ComponentRange | null = null
+ // Open containers; a container inside another one is part of the outer one's range
+ let depth = 0
const entries: (Handler | null)[] = [
{
selector: `${componentBehavior.metadata.cssSelector}`,
componentKey,
handler: {
- element: () => {
+ element: (element: RewriterElement) => {
+ if (depth > 0) {
+ return
+ }
currentRange = { markers: [] }
markers[componentKey]?.push(currentRange)
+ if (element.onEndTag && !isVoidElement(element)) {
+ try {
+ element.onEndTag(() => {
+ depth--
+ })
+ depth++
+ } catch {
+ // No end tag, so nothing can be nested in it
+ }
+ }
},
},
},
@@ -81,7 +97,7 @@ export const findMarkerPositions = async (
nextEndMarker: null,
}
currentRange.markers[elementCounter] = marker
- if (element.onEndTag) {
+ if (element.onEndTag && !isVoidElement(element)) {
try {
element.onEndTag(() => {
marker.endTag = elementCounter
diff --git a/src/stages/voidElements.ts b/src/stages/voidElements.ts
new file mode 100644
index 00000000..a77c09e0
--- /dev/null
+++ b/src/stages/voidElements.ts
@@ -0,0 +1,24 @@
+import type { RewriterElement } from '../adapters/HtmlRewriterAdapter'
+
+const VOID_ELEMENTS = new Set([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+])
+
+/**
+ * HTML void elements have no end tag, and rewriter runtimes throw when `onEndTag` is called on them. Self-closing
+ * foreign elements (` ` in SVG) throw too but cannot be recognised by name, so callers keep a try/catch.
+ */
+export const isVoidElement = (element: RewriterElement): boolean => VOID_ELEMENTS.has(element.tagName)
diff --git a/test/stages/surfaceComponents.spec.ts b/test/stages/surfaceComponents.spec.ts
index 5bf53baa..b7b79220 100644
--- a/test/stages/surfaceComponents.spec.ts
+++ b/test/stages/surfaceComponents.spec.ts
@@ -332,6 +332,78 @@ describe('handleSurfaceComponents', () => {
expect(events).toContainEqual(expect.objectContaining({ level: 'warn', code: 'replacement-markers-identical' }))
})
+ describe('replaceRange with nested matches', () => {
+ const replaceRange = async (body: string, range: { fromMarker?: string }, cssSelector = '.c') => {
+ const decision = decisions({
+ range: {
+ metadata: { cssSelector },
+ content: { replaceRange: { ...range, replaceWith: [{ type: 'html', content: 'NEW ' }] } },
+ },
+ })
+ return (await handleSurfaceComponents(baseCtx, html(body), decision, new LolHtmlRewriter())).text()
+ }
+
+ it('starts the range at the first start marker when it holds another', async () => {
+ const body = await replaceRange('
', {
+ fromMarker: '.s',
+ })
+ expect(body).toBe('')
+ })
+
+ it('starts the range at the first start marker when both are nested in the container', async () => {
+ const body = await replaceRange(
+ '',
+ {
+ fromMarker: '.s',
+ },
+ )
+ expect(body).toBe('')
+ })
+
+ it('applies the range to the outermost of nested containers', async () => {
+ const body = await replaceRange(
+ '',
+ { fromMarker: 'p.s' },
+ )
+ expect(body).toBe(
+ '',
+ )
+ })
+
+ it('replaces the whole outermost container when nested containers have no markers', async () => {
+ const body = await replaceRange('', {})
+ expect(body).toBe('NEW
')
+ })
+
+ it('still cuts a later container after one removed by another component', async () => {
+ const decision = decisions({
+ gone: { metadata: { cssSelector: '#gone' }, content: { remove: true } },
+ range: {
+ metadata: { cssSelector: '.c' },
+ content: { replaceRange: { fromMarker: 'p.s', replaceWith: [{ type: 'html', content: 'NEW ' }] } },
+ },
+ })
+ const page =
+ ''
+ const result = await handleSurfaceComponents(baseCtx, html(page), decision, new LolHtmlRewriter())
+ expect(await result.text()).toBe('')
+ })
+
+ it('applies the range to a container matched by a selector list', async () => {
+ const body = await replaceRange('', { fromMarker: 'p.s' }, '.x, .c')
+ expect(body).toBe('')
+ })
+
+ it('applies the range to the outermost of nested containers matched by a descendant selector', async () => {
+ const body = await replaceRange(
+ ' ',
+ { fromMarker: 'p.s' },
+ 'main .c',
+ )
+ expect(body).toBe(' ')
+ })
+ })
+
it('falls back gracefully when findMarkerPositions rejects a marker selector — other components still apply', async () => {
const { events, logger } = createRecordingLogger()
const response = html('leave
orig
')
diff --git a/test/stages/voidElements.spec.ts b/test/stages/voidElements.spec.ts
new file mode 100644
index 00000000..d046ac3e
--- /dev/null
+++ b/test/stages/voidElements.spec.ts
@@ -0,0 +1,175 @@
+import { describe, expect, it } from 'vitest'
+import type { ElementHandlers, HtmlRewriterAdapter, HtmlRewriterSession, RewriterElement } from '../../src/adapters/HtmlRewriterAdapter'
+import { normalizeMOSConfig } from '../../src/config'
+import type { PipelineContext } from '../../src/context'
+import handleSurfaceComponents from '../../src/stages/surfaceComponents'
+import { isVoidElement } from '../../src/stages/voidElements'
+import type { SurfaceDecisionResponse } from '../../src/types'
+import { LolHtmlRewriter } from '../fakes/LolHtmlRewriter'
+
+const ctx: PipelineContext = {
+ config: normalizeMOSConfig({
+ originUrl: 'https://origin.example.com',
+ surfaceSlug: 'web',
+ mosHost: 'https://api.monetizationos.com',
+ mosSecretKey: 'sk_env_test_abc',
+ anonymousSessionCookieName: 'anon-session',
+ authenticatedUserJwtCookieName: '__session',
+ }),
+ logger: { log() {} },
+}
+
+/** Wraps the real rewriter and records the tag of every element `onEndTag` is called on. */
+class EndTagSpyRewriter implements HtmlRewriterAdapter {
+ readonly inner = new LolHtmlRewriter()
+ readonly capabilities = this.inner.capabilities
+ readonly endTagCalls: string[] = []
+
+ // Methods are bound to the element, which a real rewriter element needs
+ private spyOn(element: RewriterElement): RewriterElement {
+ const onEndTag: RewriterElement['onEndTag'] = (callback) => {
+ this.endTagCalls.push(element.tagName.toLowerCase())
+ element.onEndTag?.(callback)
+ }
+ return new Proxy(element, {
+ get: (target, prop) => {
+ if (prop === 'onEndTag') {
+ return onEndTag
+ }
+ const value = Reflect.get(target, prop)
+ return typeof value === 'function' ? value.bind(target) : value
+ },
+ })
+ }
+
+ create(): HtmlRewriterSession {
+ const session = this.inner.create()
+ const spy: HtmlRewriterSession = {
+ on: (selector: string, handlers: ElementHandlers) => {
+ session.on(selector, {
+ ...handlers,
+ element: handlers.element ? (element) => handlers.element?.(this.spyOn(element)) : undefined,
+ })
+ return spy
+ },
+ transform: (response) => session.transform(response),
+ }
+ return spy
+ }
+}
+
+const decisions = (replaceRange: { fromMarker?: string; toMarker?: string }, cssSelector = 'div.hero'): SurfaceDecisionResponse => ({
+ status: 'success',
+ identity: { identifier: 'anon', isAuthenticated: false, authType: 'anonymous', jwtClaims: {} },
+ features: {},
+ customer: { hasProducts: false },
+ surfaceBehavior: {},
+ componentsSkipped: false,
+ componentBehaviors: {
+ hero: {
+ metadata: { cssSelector },
+ content: { replaceRange: { ...replaceRange, replaceWith: [{ type: 'html', content: 'NEW ' }] } },
+ },
+ },
+})
+
+const run = async (body: string, replaceRange: { fromMarker?: string; toMarker?: string }, cssSelector?: string) => {
+ const rewriter = new EndTagSpyRewriter()
+ const response = new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
+ const result = await handleSurfaceComponents(ctx, response, decisions(replaceRange, cssSelector), rewriter)
+ return { body: await result.text(), endTagCalls: rewriter.endTagCalls }
+}
+
+describe('range replacement with void elements', () => {
+ const VOID_TAGS = ['br', 'img', 'hr', 'input', 'meta', 'link', 'wbr']
+
+ it('does not call onEndTag on void elements and replaces the range as before', async () => {
+ const { body, endTagCalls } = await run(
+ '',
+ { fromMarker: 'span.start', toMarker: 'span.end' },
+ )
+ expect(body).toBe('S NEW E
')
+ for (const tag of VOID_TAGS) {
+ expect(endTagCalls).not.toContain(tag)
+ }
+ expect(endTagCalls).toContain('p')
+ })
+
+ it('cuts past void elements written with an end tag, which the cut leaves behind', async () => {
+ const { body, endTagCalls } = await run(
+ 'S kill
E ',
+ { fromMarker: 'span.start', toMarker: 'span.end' },
+ )
+ // The rewriter never hands a stray end tag to a handler, so cutting element by element can't remove it
+ expect(body).toBe('S NEW E
')
+ expect(endTagCalls).not.toContain('img')
+ expect(endTagCalls).not.toContain('br')
+ })
+
+ it('handles a void element as the fromMarker without calling onEndTag on it', async () => {
+ const { body, endTagCalls } = await run(
+ '',
+ { fromMarker: 'hr.start', toMarker: 'span.end' },
+ )
+ expect(body).toBe('')
+ expect(endTagCalls).not.toContain('hr')
+ })
+})
+
+describe('isVoidElement', () => {
+ const element = (tagName: string) => ({ tagName }) as RewriterElement
+
+ it.each([
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+ ])('treats <%s> as void', (tag) => {
+ expect(isVoidElement(element(tag))).toBe(true)
+ })
+
+ it('does not treat elements with an end tag as void', () => {
+ expect(isVoidElement(element('p'))).toBe(false)
+ })
+})
+
+describe('range replacement when the container has no end tag', () => {
+ it.each([
+ ['without markers', {}],
+ ['with a fromMarker', { fromMarker: 'span' }],
+ ])('leaves a void container alone %s', async (_, replaceRange) => {
+ const html = 'a
b
'
+ const { body } = await run(html, replaceRange, 'img.hero')
+ expect(body).toBe(html)
+ })
+
+ it('leaves a self-closing foreign container alone', async () => {
+ const html = 'b
'
+ const { body } = await run(html, {}, 'path.hero')
+ expect(body).toBe(html)
+ })
+
+ it('pops the state of a void container nested in a matching container', async () => {
+ const { body } = await run(
+ 's x
',
+ { fromMarker: '.s' },
+ '.hero',
+ )
+ expect(body).toBe('s NEW
')
+ })
+
+ it('still replaces a later container once an earlier match had no end tag', async () => {
+ const { body } = await run(' ', {}, '.hero')
+ expect(body).toBe('NEW
')
+ })
+})