Skip to content

fix(js,cdp): implement label activation behaviour - #728

Merged
SGavrl merged 1 commit into
h4ckf0r0day:mainfrom
Vrejf:pr/label-activation
Aug 28, 2026
Merged

fix(js,cdp): implement label activation behaviour#728
SGavrl merged 1 commit into
h4ckf0r0day:mainfrom
Vrejf:pr/label-activation

Conversation

@Vrejf

@Vrejf Vrejf commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #721.

Activating a <label> runs a synthetic click on its labeled control: the element referenced by for, else the first labelable descendant. Neither HTMLElement.click() nor the CDP click path did this, so a checkbox or radio built as a styled label could not be operated at all. That is the whole of Webflow's .w-checkbox markup and most styled-checkbox patterns.

Checkbox and radio pre-click activation runs with it, since without it a click event dispatched but nothing ever toggled: state flips before the click event so listeners observe the new value, reverts if the event is cancelled, and input then change fire after an uncancelled toggle.

The association and activation rules that go with it, each verified against headless Chrome: an empty for associates nothing rather than falling back to a descendant; a dangling for activates nothing; for wins over a nested control; a disabled control has no activation behaviour, including under a disabled <fieldset> outside its first <legend> child; a click on interactive content inside a label keeps that content's own behaviour, while meter, output and progress are labelable but not interactive and still forward; a control clicking its own label cannot bounce back.

The CDP path calls shared helpers for association, disabled state, and interactive content rather than restating those rules, so the two click paths cannot drift apart on them. Forwarded events inherit the trustedness of the click that caused them, so a physical label click produces a trusted control click as it does in a real browser.

Verification

16 spec edge cases, each through both element.click() and CDP Input.dispatchMouseEvent, on two DOM shapes: 64/64 exact match with headless Chrome 145. Stock fails 11-12 of 16.

Cases that were wrong in my own first attempt and are now covered by tests, all measured against Chrome:

probe Chrome naive implementation now
disabledCheckbox.click() no toggle, no event toggles no toggle, no event
<div disabled>.click() dispatches suppressed dispatches
<a> inside <fieldset disabled> dispatches suppressed dispatches
click <a href> inside a label no activation activates no activation
click <output> inside a label activates no activation activates
<legend> wrapped in a <div> disabled enabled disabled
inner fieldset legend, outer fieldset disabled disabled enabled disabled
CDP click on a disabled control no toggle, no event toggles no toggle, no event
  • cargo nextest run --release --features render --no-fail-fast: 1497 run, 1494 passed. All three failures pass individually and also fail on unmodified main; they are timing-sensitive intersection_observer and budget tests, measured at 2-3/10 on both trees.
  • Obstacle course: 32/33, failing observer-intersection identically on main.
  • New coverage: six runtime tests and an end-to-end CDP test (input_mouse_label_activation) asserting event type, order, count and isTrusted for explicit, implicit and deep-descendant labels, plus a disabled control.
  • On a real page built with this markup, three option clicks now produce the same selected state as Chrome (0 to 3), where stock produces none.

Notes for review

Activating a label runs a synthetic click on its labeled control: the
element referenced by for, else the first labelable descendant. Neither
HTMLElement.click() nor the CDP click path did this, so a checkbox or
radio built as a styled label could not be operated at all. Checkbox and
radio pre-click activation runs with it, flipping state before the click
event so listeners observe the new value, reverting on cancel, and firing
input then change after an uncancelled toggle.

A disabled control has no activation behaviour, including under a
disabled fieldset outside its first legend child, and only elements that
can actually be disabled are affected. A click on interactive content
inside a label keeps that content's own behaviour. Forwarded events
inherit the trustedness of the click that caused them.

The CDP path calls the same helpers for association, disabled state, and
interactive content instead of restating those rules.

Fixes h4ckf0r0day#721.
@yinnho

yinnho commented Aug 26, 2026

Copy link
Copy Markdown

Reviewed the three PRs together (#727/#728/#729) — the two-level guard composes correctly, matching what we traced through the expanded matrix on our build.

Own-label bounce, implicit label (checkbox C inside label L, C's handler calls L.click()):

  1. L.click(): L not in _clickInProgress → dispatch L → forwarding: _labeledControl(L)=C, _forwardingLabels.has(L)=false → add L → C.click()
  2. C.click(): C not in _clickInProgress → flip C → dispatch C → handler calls L.click()
  3. L.click(): L still not in _clickInProgress (label-level set is separate) → dispatch L → forwarding: _forwardingLabels.has(L)=true → skip → returns

So the element-level WeakSet (#729) stops direct self-reentry, and the closure-private _forwardingLabels stops the label→control bounce — the control flips exactly once, and a cancelled dispatch restores via the existing _oldChecked path. The element-level flag sitting above the pre-activation flip (not below it) is what keeps state from being stuck post-flip on the suppressed entry.

One residual difference on our side: we went ahead and implemented indeterminate as a real IDL property (prototype getter/setter over a per-element store, since the checkbox activation path clears it — see yinnho/aginxbrowser@88b8496). Obscura has no such property so there's nothing to clear here; flagging only so the option is on the table if 'indeterminate' in checkbox ever matters for a page.

@Vrejf

Vrejf commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for tracing it. I merged all three locally and the composed behaviour is what you describe, but one step of the mechanism is not:

At step 3 L is already in _clickInProgress. Step 1 added it before dispatching and only removes it in finally, so the nested L.click() is suppressed by the element-level flag, not by _forwardingLabels. The two sets are separate, but they are keyed on different elements in the same call stack rather than being independent of each other.

_forwardingLabels is still load-bearing, just on the other path. A CDP click never calls L.click(): it dispatches its own trusted event and calls __obscura_activateLabel directly, so _clickInProgress never holds the label and the label-level set is the only thing stopping the bounce. Measured on the composed tree, both paths match headless Chrome:

path Chrome composed
L.click(), implicit label, control handler clicks L checked=true, change=1, control dispatches=1 same
CDP mouse click on L, same handler checked=true, change=1, control dispatches=1 same

So both guards are needed, and the ordering point in #729 holds: the element flag has to sit above the pre-activation flip. The merge is not clean, incidentally: #728 and #729 both edit click() and conflict, and resolving it by taking either side alone silently drops one of the two guards.

On indeterminate, agreed that implementing the IDL property is the right call. I left it out of #728 deliberately rather than write the activation step against a property that does not exist, since setting it would only create an expando. Worth its own issue upstream.

@yinnho

yinnho commented Aug 26, 2026

Copy link
Copy Markdown

You're right — my step-3 claim was wrong. L was added to _clickInProgress at step 1 and only removed in finally, so the JS-path bounce is stopped by the element-level flag, not the label set. The label-level _forwardingLabels is what carries the CDP path (which never re-enters L.click()), so the two guards cover different call paths — that's the cleaner way to put it, and it matches your measured table. Good catch on the merge conflict too: taking either #728 or #729 alone silently drops one guard.

On indeterminate — opening the upstream issue now.

@SGavrl
SGavrl merged commit 6bd16cc into h4ckf0r0day:main Aug 28, 2026
3 checks passed
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.

Label activation is not implemented: clicking a <label> never toggles its control (element.click() and CDP)

3 participants