Skip to content

fix: escape IDs in restorePreservedElements, selectOOB, and anchor scroll - #3982

Closed
n0vdd wants to merge 1 commit into
bigskysoftware:devfrom
n0vdd:fix/css-escape-remaining-selectors
Closed

fix: escape IDs in restorePreservedElements, selectOOB, and anchor scroll#3982
n0vdd wants to merge 1 commit into
bigskysoftware:devfrom
n0vdd:fix/css-escape-remaining-selectors

Conversation

@n0vdd

@n0vdd n0vdd commented Aug 27, 2026

Copy link
Copy Markdown

Description

Three remaining call sites build a CSS selector from a raw element id without CSS.escape(), causing failures when the id contains CSS-special characters (., /, :, etc.):

  1. restorePreservedElements (line ~1521) — find('#' + preservedElt.id)querySelector('#' + id). On browsers with moveBefore (Chrome), htmx moves the preserved element into the pantry div during handlePreservedElements, then restorePreservedElements looks it back up by id to put it in place. A dotted id like payment.parcelas becomes the selector #payment.parcelas (id=payment AND class=parcelas), matches nothing, and the next line throws TypeError: Cannot read properties of null (reading 'parentNode'), aborting the swap.

  2. selectOOB handling (line ~1929) — fragment.querySelector('#' + id). Same pattern: the id from hx-select-oob is interpolated raw.

  3. Anchor scroll (line ~2004) — resolveTarget('#' + swapOptions.anchor)find()querySelector(). An anchor id with special chars hits the same parse error.

The sibling call sites in oobSwap (fixed by #3304) and handleAttributes (fixed by #3752) already use CSS.escape() correctly — these three were missed.

Repro (restorePreservedElements — the most visible one)

<div hx-get="/update">
  <input id="payment.amount" hx-preserve value="typed-value">
</div>

On Chrome (moveBefore path): swap replaces the content, handlePreservedElements stashes the input in the pantry via getElementById (works fine — getElementById treats id as opaque), then restorePreservedElements tries querySelector('#payment.amount') → no match → TypeError on .parentNode. The preserved element is stranded in the pantry div and all subsequent swaps on the page may break.

On Firefox/Safari (replaceChild path): handlePreservedElements takes the else branch and calls replaceChild directly — restorePreservedElements is never called, so the bug is latent.

Corresponding issues: the same bug class as #1537 (oobSwap) and the settle-lookup in #3752, applied to the three remaining unescaped sites.

Testing

  • Added handles hx-preserve on elements with dotted IDs to test/attributes/hx-preserve.js
  • Added handles elements with IDs containing dots in hx-select-oob to test/attributes/hx-select-oob.js
  • Full test suite: 849 passed, 0 failed, 3 skipped (Chrome)

Checklist

  • I have read the contribution guidelines
  • I have targeted this PR against the correct branch (dev for source changes)
  • This is a bugfix
  • I ran the test suite locally (npm run test:chrome) and verified that it succeeded

…roll

Three call sites build a CSS selector from a raw element id without
CSS.escape(), causing failures when the id contains CSS-special characters
(dots, slashes, colons, etc.).

- restorePreservedElements: find('#' + preservedElt.id) breaks on the
  moveBefore path (Chrome) with dotted ids — the selector parses as
  id + class, matches nothing, throws TypeError on .parentNode.
- selectOOB: fragment.querySelector('#' + id) — same pattern.
- anchor scroll: resolveTarget('#' + swapOptions.anchor) — same pattern.

The sibling call sites in oobSwap (fixed by bigskysoftware#3304) and handleAttributes
(fixed by bigskysoftware#3752) already use CSS.escape() correctly.
@1cg

1cg commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Thank you for digging into this, and for the clear repro on each of the three call sites.

We are going to leave this one to htmx 4 rather than change 2.x.

The reason is the hx-select-oob site. Escaping the id there locks the value to a literal id, which is the opposite of what #2561 asks for. htmx 4 already went the other way: it uses the value as a real selector and drops the # prefix entirely, so the escape is not needed and the general selector support people want is there.

for (let spec of selectOOB.split(',')) {
    let [selector, oobValue = 'true'] = spec.split(/:(.*)/);
    for (let elt of fragment.querySelectorAll(selector)) {
        this.__createOOBTask(tasks, elt, oobValue, sourceElement);
    }
}

htmx 4 also uses CSS.escape where an id really is an id, in __createOOBTask.

Your analysis was correct and useful. Thank you!

@1cg 1cg closed this Sep 6, 2026
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.

2 participants