Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/validation/css-user-validity-state-20260919.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# CSS user-validity state source contract

## Scope and baseline

This implementation-first slice starts from exact WebScene `main`
`c45cd36ce59c264206aace5d0d3f524e8ded675c`. The issue #257 audit found that
`:enabled` / `:disabled`, `:checked` / `:indeterminate`, and `:in-range` /
`:out-of-range` already have matcher and dependency paths. The selector parser
recognized `:user-valid` and `:user-invalid`, but both fell through matching and
there was no user-interaction state or invalidation dependency.

The implemented boundary adds one non-reflected boolean to the existing cold
form-control record. Native text edits and accessibility value actions set it.
JavaScript `value`, `defaultValue`, textarea child text, and attribute writes do
not create user interaction; after interaction they may change whether the
control is user-valid or user-invalid. Reset through the current form owner
clears the state. Detach and reparent preserve the element-owned state.

Both user pseudos consume the same current live-value validity classification
as `:valid` and `:invalid`. This slice therefore does not claim a broader
constraint-validation implementation. Checkbox/radio required-value rules,
select placeholder rules, pattern/type/step mismatch, date/time range types,
form/fieldset aggregation, submit-attempt state, external `form=` reset, and
the constraint-validation IDL remain later #257 work.

## Invalidation and ownership

The selector dependency compiler records `$live-form-user-validity` for the
two user pseudos alongside their existing `required`, `value`, live-value, and
textarea-child dependencies. The existing live form-value transition compares
the old and new value classification and user-interaction bit in one checkpoint.
It recascades through the compiled selector route only when one of those inputs
changes. Programmatic nonempty-to-nonempty writes after interaction remain a
paint/layout dirty operation without unnecessary user-validity selector work.

No child-list implementation is added. Textarea default-value changes continue
to reuse the merged structural route, and detach/reparent use the existing DOM
mutation checkpoints. Ordinary DOM nodes retain no new inline field because
the boolean remains in pay-for-use `form_control_data`.

## Authored source contracts

- `tests/WebPlatformSubset/contracts/css-user-validity-state.html` derives a
browser-shaped native-input sequence from CSS UI user-validity behavior and
checks selector APIs, computed style, programmatic writes, required mutation,
detach/reparent, and owner-specific reset.
- The existing `live-form-state` native gate now checks the same distinctions
using the host keyboard-input path beside its 4,096-control bounded
invalidation gate.

## Validation status

Per the task constraint, no build, browser/WPT run, native test, benchmark,
scene/pixel check, product check, package gate, or CI job was run. Only
`git diff --check` is used for this commit. The new profile entry remains a
candidate until direct Chromium and native execution establish parity.
Original file line number Diff line number Diff line change
Expand Up @@ -317,20 +317,15 @@ inline bool compound_matches(const Host& host,const dom_node& node,
} else if (name == "read-only") {
if (css::is_read_write(document,node)) return false;
} else if (name == "valid") {
if (!form_control || node.tag == "fieldset" || node.tag == "optgroup"
|| node.tag == "option") return false;
if (node.attributes.contains("required")) {
if (forms::supports_text_selection(&node)
? forms::text_value_empty(node)
: !node.attributes.contains("value")
|| node.attributes.at("value").empty()) return false;
}
if (forms::validity_state(node) != forms::simple_validity_state::valid) return false;
} else if (name == "invalid") {
if (!form_control || !node.attributes.contains("required")) return false;
if (forms::supports_text_selection(&node)
? !forms::text_value_empty(node)
: node.attributes.contains("value")
&& !node.attributes.at("value").empty()) return false;
if (forms::validity_state(node) != forms::simple_validity_state::invalid) return false;
} else if (name == "user-valid") {
if (!node.form_control().user_validity_interacted
|| forms::validity_state(node) != forms::simple_validity_state::valid) return false;
} else if (name == "user-invalid") {
if (!node.form_control().user_validity_interacted
|| forms::validity_state(node) != forms::simple_validity_state::invalid) return false;
} else if (name == "placeholder-shown") {
if (!forms::supports_placeholder_selector(node)
|| !node.attributes.contains("placeholder")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ inline std::vector<css_compound_dependencies> compile_invalidation_plan(
output.child_list_sensitive = true;
add(output.child_list, inherited_route);
} else if (pseudo.name == "required" || pseudo.name == "optional"
|| pseudo.name == "valid" || pseudo.name == "invalid") {
|| pseudo.name == "valid" || pseudo.name == "invalid"
|| pseudo.name == "user-valid" || pseudo.name == "user-invalid") {
add(output.attributes["required"], route);
if (pseudo.name == "required" || pseudo.name == "optional") {
add(output.attributes["type"], route);
Expand All @@ -121,6 +122,8 @@ inline std::vector<css_compound_dependencies> compile_invalidation_plan(
// text children. Reuse the structural route machinery.
output.child_list_sensitive = true;
add(output.child_list, route);
if (pseudo.name == "user-valid" || pseudo.name == "user-invalid")
add(output.attributes["$live-form-user-validity"], route);
}
} else if (pseudo.name == "placeholder-shown") {
add(output.attributes["placeholder"], route);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1227,8 +1227,8 @@
return std::all_of(values_.begin(), values_.end(), [&other](const auto& entry) {
const auto match = other.find(entry.first);
return match != other.end() && match->second == entry.second
&& namespace_uri(entry.first) == other.namespace_uri(entry.first)

Check failure on line 1230 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

'this' cannot be implicitly captured because no default capture mode has been specified

Check failure on line 1230 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

'this' cannot be implicitly captured because no default capture mode has been specified
&& local_name(entry.first) == other.local_name(entry.first);

Check failure on line 1231 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

'this' cannot be implicitly captured because no default capture mode has been specified

Check failure on line 1231 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

'this' cannot be implicitly captured because no default capture mode has been specified
});
}

Expand Down Expand Up @@ -1391,6 +1391,7 @@
bool indeterminate{false};
bool value_initialized{false};
bool dirty_value{false};
bool user_validity_interacted{false};
bool input_focused{false};
bool caret_visible{false};
};
Expand Down Expand Up @@ -2493,7 +2494,7 @@
// ABI-specific equalities: libc++, libstdc++, and MSVC intentionally use
// different std::string and container representations.
static_assert(
sizeof(void*) != 8 || sizeof(dom_node) <= 1024,

Check failure on line 2497 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

static_assert failed: 'dom_node exceeded its cross-library 64-bit footprint budget'

Check failure on line 2497 in experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h

View workflow job for this annotation

GitHub Actions / Build win-x64

static_assert failed: 'dom_node exceeded its cross-library 64-bit footprint budget'
"dom_node exceeded its cross-library 64-bit footprint budget");
static_assert(
sizeof(void*) != 8 || sizeof(dom_node::form_control_data) <= 64,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ enum class numeric_range_state : uint8_t {
out_of_range,
};

enum class simple_validity_state : uint8_t {
not_applicable,
valid,
invalid,
};

inline bool form_keyword_equals(std::string_view value,std::string_view expected)
{
if(value.size()!=expected.size()) return false;
Expand Down Expand Up @@ -288,6 +294,22 @@ inline bool text_value_empty(const dom_node& node) {
return attribute==node.attributes.end() || attribute->second.empty();
}

inline simple_validity_state validity_state(const dom_node& node) {
const auto form_control = node.tag == "button" || node.tag == "input"
|| node.tag == "select" || node.tag == "textarea"
|| node.tag == "option" || node.tag == "optgroup" || node.tag == "fieldset";
if (!form_control || node.tag == "fieldset" || node.tag == "optgroup"
|| node.tag == "option") return simple_validity_state::not_applicable;
if (node.attributes.contains("required")) {
const auto empty = supports_text_selection(&node)
? text_value_empty(node)
: !node.attributes.contains("value")
|| node.attributes.at("value").empty();
if (empty) return simple_validity_state::invalid;
}
return simple_validity_state::valid;
}

inline bool supports_placeholder_selector(const dom_node& node) {
return (node.tag=="input" || node.tag=="textarea")
&& supports_text_selection(&node);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2401,14 +2401,19 @@
void recascade_form_value_selector_transition(
dom_node& subject,
std::string previous_value,
std::string current_value)
std::string current_value,
std::optional<bool> user_validity_interacted = std::nullopt)
{
document.mark_dirty();
const auto empty_state_changed=previous_value.empty()!=current_value.empty();
const auto previous_range=forms::range_state(subject,previous_value);
const auto current_range=forms::range_state(subject,current_value);
const auto range_state_changed=previous_range!=current_range;
if(!empty_state_changed && !range_state_changed) return;
const auto previous_user_validity=subject.form_control().user_validity_interacted;
const auto current_user_validity=user_validity_interacted.value_or(previous_user_validity);
const auto user_validity_changed=previous_user_validity!=current_user_validity;
subject.mutable_form_control().user_validity_interacted=current_user_validity;
if(!empty_state_changed && !range_state_changed && !user_validity_changed) return;
webscene_frame_trace::scope invalidation_trace(
frame_trace,
"form-state-invalidation-start",
Expand All @@ -2424,6 +2429,7 @@
};
if(empty_state_changed) append_rules("$live-form-value");
if(range_state_changed) append_rules("$live-form-range");
if(user_validity_changed) append_rules("$live-form-user-validity");
std::ranges::sort(changed_rule_storage);
changed_rule_storage.erase(
std::unique(changed_rule_storage.begin(),changed_rule_storage.end()),
Expand All @@ -2441,10 +2447,19 @@
};
if(empty_state_changed) include("$live-form-value");
if(range_state_changed) include("$live-form-range");
if(user_validity_changed) include("$live-form-user-validity");
return result;
},
[&] { subject.mutable_form_control().value = previous_value; },
[&] { subject.mutable_form_control().value = current_value; });
[&] {
auto& control=subject.mutable_form_control();
control.value=previous_value;
control.user_validity_interacted=previous_user_validity;
},
[&] {
auto& control=subject.mutable_form_control();
control.value=current_value;
control.user_validity_interacted=current_user_validity;
});
}

void recascade_class_selector_transition(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3676,7 +3676,9 @@
}
if (self->is_connected(node)) {
self->recascade_form_value_selector_transition(
node, previous_value, node.form_control().value);
node, previous_value, node.form_control().value, false);
} else {
node.mutable_form_control().user_validity_interacted = false;
}
}
for (auto* child : node.children) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@
node.mutable_form_control().value_initialized = true;
node.mutable_form_control().dirty_value = true;
recascade_form_value_selector_transition(
node, previous, node.form_control().value);
node, previous, node.form_control().value, true);
webscene_input_event synthetic{};
if (!dispatch_input_event_type(synthetic, "input", node)
|| !dispatch_input_event_type(synthetic, "change", node)) {
Expand Down Expand Up @@ -1086,7 +1086,7 @@
text_selection_direction::none;
node->mutable_form_control().selection_explicitly_set = true;
recascade_form_value_selector_transition(
*node, previous, node->form_control().value);
*node, previous, node->form_control().value, true);
succeeded = dispatch_input_event_type(synthetic, "input", *node);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2824,7 +2824,7 @@
target->mutable_form_control().caret_visible = true;
caret_blink_epoch_ms = last_animation_frame_timestamp_ms;
recascade_form_value_selector_transition(
*target, previous_value, target->form_control().value);
*target, previous_value, target->form_control().value, true);
return dispatch_input_event_type(input, "input", *target);
}

Expand Down Expand Up @@ -3076,7 +3076,7 @@
target->mutable_form_control().selection_direction = text_selection_direction::none;
target->mutable_form_control().selection_explicitly_set = true;
recascade_form_value_selector_transition(
*target, previous_value, target->form_control().value);
*target, previous_value, target->form_control().value, true);
if (!dispatch_input_event_type(input, "input", *target)) return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ void test_live_form_state_selectors_and_scaling()

const auto correctness = evaluate(engine, R"JS((() => {
document.body.innerHTML = `<style>
.probe {padding-left:19px;padding-top:17px}
.probe {padding-left:19px;padding-top:17px;margin-left:5px;margin-top:7px}
.probe:invalid {padding-left:47px}
.probe:placeholder-shown {padding-top:83px}
.probe:user-valid {margin-left:43px}
.probe:user-invalid {margin-top:61px}
</style><form id="form"></form>`;
const form=document.getElementById('form');
const textarea=document.createElement('textarea');
Expand Down Expand Up @@ -54,20 +56,40 @@ void test_live_form_state_selectors_and_scaling()
const auto native_setup = evaluate(engine, R"JS((() => {
const input=document.createElement('input'); input.id='native-value';
input.className='probe'; input.setAttribute('required',''); input.setAttribute('placeholder','type');
document.body.appendChild(input); input.focus(); return [
document.getElementById('form').appendChild(input); input.focus(); return [
input.matches(':invalid'),input.matches(':placeholder-shown'),
getComputedStyle(input).paddingLeft,getComputedStyle(input).paddingTop];
input.matches(':user-valid'),input.matches(':user-invalid'),
getComputedStyle(input).paddingLeft,getComputedStyle(input).paddingTop,
getComputedStyle(input).marginLeft,getComputedStyle(input).marginTop];
})())JS", "live-form-state-native-setup.js");
require(native_setup == R"([true,true,"47px","83px"])",
require(native_setup == R"([true,true,false,false,"47px","83px","5px","7px"])",
"native form-state setup diverged: " + native_setup);
keyboard_input(engine, WEBSCENE_INPUT_TEXT, 'x', 2U);
wait_for_consumed_inputs(engine, 2U, "native form-state input was not consumed");
const auto native_result = evaluate(engine, R"JS((() => {const input=document.getElementById('native-value');return [
input.value,input.matches(':valid'),input.matches(':invalid'),
input.matches(':placeholder-shown'),getComputedStyle(input).paddingLeft,
getComputedStyle(input).paddingTop];})())JS", "live-form-state-native-result.js");
require(native_result == R"(["x",true,false,false,"19px","17px"])",
input.matches(':placeholder-shown'),input.matches(':user-valid'),
input.matches(':user-invalid'),getComputedStyle(input).paddingLeft,
getComputedStyle(input).paddingTop,getComputedStyle(input).marginLeft,
getComputedStyle(input).marginTop];})())JS", "live-form-state-native-result.js");
require(native_result == R"(["x",true,false,false,true,false,"19px","17px","43px","7px"])",
"native input did not invalidate live form state: " + native_result);
const auto user_validity_lifecycle = evaluate(engine, R"JS((() => {
const input=document.getElementById('native-value'),first=document.getElementById('form');
const snapshot=()=>{const style=getComputedStyle(input);return [
input.matches(':valid'),input.matches(':invalid'),input.matches(':user-valid'),
input.matches(':user-invalid'),style.marginLeft,style.marginTop];};
input.value='';const states=[snapshot()];
input.removeAttribute('required');states.push(snapshot());
input.setAttribute('required','');input.remove();states.push([
input.matches(':valid'),input.matches(':invalid'),input.matches(':user-valid'),
input.matches(':user-invalid')]);
const second=document.createElement('form');document.body.appendChild(second);second.appendChild(input);
first.reset();states.push(snapshot());second.reset();states.push(snapshot());
return states;
})())JS", "live-form-user-validity-lifecycle.js");
require(user_validity_lifecycle == R"JSON([[false,true,false,true,"5px","61px"],[true,false,true,false,"43px","7px"],[false,true,false,true],[false,true,false,true,"5px","61px"],[false,true,false,false,"5px","7px"]])JSON",
"live user-validity lifecycle diverged: " + user_validity_lifecycle);

const auto setup = evaluate(engine, R"JS((() => {
document.body.innerHTML='<style>.scale:invalid{width:2px}.scale:placeholder-shown{height:3px}'+
Expand Down
46 changes: 46 additions & 0 deletions tests/WebPlatformSubset/contracts/css-user-validity-state.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<!doctype html>
<meta charset="utf-8">
<title>User validity is live, non-reflected form-control state</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<style>
.probe {padding-left:11px;padding-top:13px}
.probe:user-valid {padding-left:43px}
.probe:user-invalid {padding-top:61px}
</style>
<form id="first"><input id="control" class="probe" required><button type="button">next</button></form>
<form id="second"></form>
<script>
promise_test(async t=>{
const first=document.getElementById('first'),second=document.getElementById('second');
const control=document.getElementById('control');
const snapshot=()=>{const style=getComputedStyle(control);return [
control.matches(':valid'),control.matches(':invalid'),
control.matches(':user-valid'),control.matches(':user-invalid'),
style.paddingLeft,style.paddingTop];};
assert_array_equals(snapshot(),[false,true,false,false,'11px','13px'],'initial invalid state is not user-invalid');

control.value='seed';
assert_array_equals(snapshot(),[true,false,false,false,'11px','13px'],'programmatic IDL value does not mark user validity');
control.value='';
await test_driver.send_keys(control,'x');
await test_driver.send_keys(control,'\uE004');
assert_array_equals(snapshot(),[true,false,true,false,'43px','13px'],'native input marks user-valid');
assert_equals(first.querySelectorAll(':user-valid').length,1,'selector API shares live user-valid state');

control.value='';
assert_array_equals(snapshot(),[false,true,false,true,'11px','61px'],'later IDL value preserves interaction and updates validity');
control.removeAttribute('required');
assert_array_equals(snapshot(),[true,false,true,false,'43px','13px'],'required removal updates user validity');
control.setAttribute('required','');
control.remove();
assert_true(control.matches(':user-invalid'),'detaching preserves element-owned interaction state');
second.appendChild(control);
first.reset();
assert_true(control.matches(':user-invalid'),'resetting the former owner does not clear reparented state');
second.reset();
assert_array_equals(snapshot(),[false,true,false,false,'11px','13px'],'current owner reset clears user validity');
},':user-valid and :user-invalid use native interaction state across value, attribute, lifecycle, and reset transitions');
</script>
Loading
Loading