From 3cd24816d4236dbd587ff94dc8b0f724e27685e2 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 5 Nov 2025 21:55:20 -0800 Subject: [PATCH 1/8] Forms: Add form fill duration --- .../class-contact-form-endpoint.php | 14 ++++++ .../src/contact-form/class-contact-form.php | 2 + .../forms/src/contact-form/class-feedback.php | 47 ++++++++++++++----- .../packages/forms/src/modules/form/view.js | 20 ++++++++ .../tests/php/contact-form/class-utility.php | 2 +- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index 8c1d7f59cf47..ee8ca5cb47c5 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -659,6 +659,16 @@ public function get_item_schema() { 'readonly' => true, ); + $schema['properties']['form_fill_duration'] = array( + 'description' => __( 'The duration in seconds from first user interaction to form submission. Returns null for submissions before this feature was added.', 'jetpack-forms' ), + 'type' => array( 'integer', 'null' ), + 'context' => array( 'view', 'edit', 'embed' ), + 'arg_options' => array( + 'sanitize_callback' => 'absint', + ), + 'readonly' => true, + ); + $schema['properties']['browser'] = array( 'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ), 'type' => 'string', @@ -991,6 +1001,10 @@ public function prepare_item_for_response( $item, $request ) { $data['country_code'] = $feedback_response->get_country_code(); } + if ( rest_is_field_included( 'form_fill_duration', $fields ) ) { + $data['form_fill_duration'] = $feedback_response->get_form_fill_duration(); + } + if ( rest_is_field_included( 'browser', $fields ) ) { $data['browser'] = $feedback_response->get_browser(); } diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 9ab52526846c..23515d9b141c 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -1544,6 +1544,7 @@ public static function parse( $attributes, $content, $context = array() ) { id='contact-form-$id' class='{$container_classes_string}' data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . " + data-wp-on--focusin=\"callbacks.trackFirstInteraction\" data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\" >\n"; @@ -1632,6 +1633,7 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label $r .= ''; } $r .= "\n"; + $r .= "\n"; $r .= $form->body; if ( $is_multistep ) { diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index c4bcbaaf281e..31eb76ac8046 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -248,6 +248,15 @@ public static function maybe_backfill_source_meta( $post_id, $feedback ) { */ protected $country_code = null; + /** + * The form fill duration in seconds. + * + * Tracks how long the user spent filling out the form (from first interaction to submission). + * + * @var int|null + */ + protected $form_fill_duration = null; + /** * The subject of the feedback entry. * @@ -427,10 +436,11 @@ private function load_from_post( WP_Post $feedback_post ) { ! empty( $parsed_content['is_test'] ) ); - $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); - $this->country_code = $parsed_content['country_code'] ?? null; - $this->user_agent = $parsed_content['user_agent'] ?? null; - $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' ); + $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); + $this->country_code = $parsed_content['country_code'] ?? null; + $this->user_agent = $parsed_content['user_agent'] ?? null; + $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null; + $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' ); $this->notification_recipients = $parsed_content['notification_recipients'] ?? array(); $this->logged_in_user = $parsed_content['logged_in_user'] ?? null; @@ -493,14 +503,15 @@ private function load_from_submission( $post_data, $form, $current_post = null, $this->form_id = $form_id_attribute > 0 ? $form_id_attribute : null; // If post_data is provided, use it to populate fields. - $this->fields = $this->get_computed_fields( $post_data, $form ); - $this->ip_address = Contact_Form_Plugin::get_ip_address(); - $this->country_code = $this->get_country_code_from_ip( $this->ip_address ); - $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; - $this->subject = $this->get_computed_subject( $post_data, $form ); - $this->author_data = Feedback_Author::from_submission( $post_data, $form ); - $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); - $this->has_consent = $this->get_computed_consent( $post_data, $form ); + $this->fields = $this->get_computed_fields( $post_data, $form ); + $this->ip_address = Contact_Form_Plugin::get_ip_address(); + $this->country_code = $this->get_country_code_from_ip( $this->ip_address ); + $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; + $this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null; + $this->subject = $this->get_computed_subject( $post_data, $form ); + $this->author_data = Feedback_Author::from_submission( $post_data, $form ); + $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); + $this->has_consent = $this->get_computed_consent( $post_data, $form ); $this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form ); @@ -1124,6 +1135,17 @@ public function get_country_code() { return $this->country_code; } + /** + * Get the form fill duration in seconds. + * + * Represents the time from first user interaction to form submission. + * + * @return int|null + */ + public function get_form_fill_duration() { + return $this->form_fill_duration; + } + /** * Get the emoji flag for the country. * @@ -1567,6 +1589,7 @@ public function serialize() { 'ip' => $this->ip_address, 'country_code' => $this->country_code, 'user_agent' => $this->user_agent, + 'form_fill_duration' => $this->form_fill_duration, 'notification_recipients' => $this->notification_recipients, 'logged_in_user' => $this->logged_in_user, ), diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index 8a5aa3b359f7..d5ec499aa016 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -672,6 +672,18 @@ const { state, actions } = store( NAMESPACE, { // Capture file preview URLs before submission (blob URLs for images, icon URLs for other files) capturedFilePreviews = captureFilePreviews( context.formHash ); + // Calculate and set the form fill duration before submission + if ( context.formFirstInteractionTime ) { + const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds + const form = document.getElementById( 'jp-form-' + context.formHash ); + if ( form ) { + const durationField = form.querySelector( 'input[name="form_fill_duration"]' ); + if ( durationField ) { + durationField.value = duration; + } + } + } + const { success, error, data, refreshArgs } = yield submitForm( context.formHash ); if ( success ) { @@ -767,6 +779,14 @@ const { state, actions } = store( NAMESPACE, { registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra ); }, + trackFirstInteraction() { + const context = getContext(); + // Store the first interaction time when user focuses on any form field + if ( ! context.formFirstInteractionTime ) { + context.formFirstInteractionTime = Date.now(); + } + }, + scrollToWrapper() { const context = getContext(); diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php index d5640d7d8a65..9169a6f9034a 100644 --- a/projects/packages/forms/tests/php/contact-form/class-utility.php +++ b/projects/packages/forms/tests/php/contact-form/class-utility.php @@ -175,7 +175,7 @@ public static function get_post_request( $values, $form_id = null, $post_id = 0 $prefix = $form_id ? $form_id : 'g' . $post_id; $post_data = array(); foreach ( $values as $key => $val ) { - if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 ) { + if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || strpos( $key, 'form_fill_duration' ) === 0 ) { $post_data[ $key ] = $val; } else { $post_data[ $prefix . '-' . $key ] = $val; From cfd07d1a72a77b4f74446ed41eeb663d696f5e80 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 5 Nov 2025 21:57:14 -0800 Subject: [PATCH 2/8] changelog --- projects/packages/forms/changelog/add-forms-submit-timer | 4 ++++ projects/plugins/jetpack/changelog/add-forms-submit-timer | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 projects/packages/forms/changelog/add-forms-submit-timer create mode 100644 projects/plugins/jetpack/changelog/add-forms-submit-timer diff --git a/projects/packages/forms/changelog/add-forms-submit-timer b/projects/packages/forms/changelog/add-forms-submit-timer new file mode 100644 index 000000000000..5d9fa9be8c51 --- /dev/null +++ b/projects/packages/forms/changelog/add-forms-submit-timer @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Forms: add form fill duration to form entries diff --git a/projects/plugins/jetpack/changelog/add-forms-submit-timer b/projects/plugins/jetpack/changelog/add-forms-submit-timer new file mode 100644 index 000000000000..c86ed6d1ca1e --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-forms-submit-timer @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Forms: Add form fill duration to feedback entries From b6aa78639cb3ca820a37471cdfd2ef5fb5dfa799 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Wed, 14 Jan 2026 11:23:14 +0200 Subject: [PATCH 3/8] Move test to a new file --- .../Feedback_Author_Metadata_Test.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php index 9c2d8f97e6c5..9e518bb42ddd 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php @@ -383,4 +383,42 @@ public function test_author_first_last_on_submission() { $this->assertSame( 'Jane', $response->get_author_first_name(), 'First name getter should return raw first name' ); $this->assertSame( 'Doe', $response->get_author_last_name(), 'Last name getter should return raw last name' ); } + + /** + * Test that form_fill_duration is included in serialized response and persists after save/load. + */ + public function test_form_fill_duration_persists_after_save() { + + $form_id = Utility::get_form_id(); + // Create a form submission with form_fill_duration + $_post_data = Utility::get_post_request( + array( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + 'form_fill_duration' => '45', // 45 seconds + ), + 'g' . $form_id + ); + + $form = new Contact_Form( + array( + 'title' => 'Test Form', + 'description' => 'This is a test form.', + ), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + + // Create a contact form response + $response = Feedback::from_submission( $_post_data, $form ); + $feedback_post_id = $response->save(); + $saved_response = Feedback::get( $feedback_post_id ); + + // The form_fill_duration should be present and match the test value. + $this->assertNotEmpty( $response->get_form_fill_duration(), 'Form fill duration should not be empty' ); + $this->assertNotEmpty( $saved_response->get_form_fill_duration(), 'Form fill duration should not be empty after save/load' ); + $this->assertEquals( $response->get_form_fill_duration(), $saved_response->get_form_fill_duration(), 'Form fill duration should match after save/load' ); + $this->assertEquals( 45, $saved_response->get_form_fill_duration(), 'Form fill duration should be 45 seconds' ); + $this->assertIsInt( $saved_response->get_form_fill_duration(), 'Form fill duration should be an integer' ); + } } From 31ab2ff2b778e4ef15e0d5aee4d155600d971b03 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 29 Jul 2026 14:43:32 -0700 Subject: [PATCH 4/8] Forms: tidy changelog entry wording --- projects/packages/forms/changelog/add-forms-submit-timer | 2 +- projects/plugins/jetpack/changelog/add-forms-submit-timer | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/forms/changelog/add-forms-submit-timer b/projects/packages/forms/changelog/add-forms-submit-timer index 5d9fa9be8c51..4c0878bfe7b1 100644 --- a/projects/packages/forms/changelog/add-forms-submit-timer +++ b/projects/packages/forms/changelog/add-forms-submit-timer @@ -1,4 +1,4 @@ Significance: minor Type: added -Forms: add form fill duration to form entries +Add form fill duration to form entries. diff --git a/projects/plugins/jetpack/changelog/add-forms-submit-timer b/projects/plugins/jetpack/changelog/add-forms-submit-timer index c86ed6d1ca1e..67502098dd6c 100644 --- a/projects/plugins/jetpack/changelog/add-forms-submit-timer +++ b/projects/plugins/jetpack/changelog/add-forms-submit-timer @@ -1,4 +1,4 @@ Significance: minor Type: enhancement -Forms: Add form fill duration to feedback entries +Forms: Add form fill duration to feedback entries. From c2bcb4321e8a3b8db931b110c10dff7c5d9cfc43 Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 29 Jul 2026 16:50:43 -0700 Subject: [PATCH 5/8] Forms: record fill duration for non-AJAX submits and keep unknown durations null --- .../class-contact-form-endpoint.php | 7 +- .../src/contact-form/class-contact-form.php | 5 +- .../forms/src/contact-form/class-feedback.php | 22 ++++- .../packages/forms/src/modules/form/shared.ts | 2 +- .../packages/forms/src/modules/form/view.js | 28 +++--- .../Feedback_Author_Metadata_Test.php | 89 +++++++++++++++++++ 6 files changed, 133 insertions(+), 20 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index ee8ca5cb47c5..8bc2a2ec8c22 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -660,12 +660,11 @@ public function get_item_schema() { ); $schema['properties']['form_fill_duration'] = array( - 'description' => __( 'The duration in seconds from first user interaction to form submission. Returns null for submissions before this feature was added.', 'jetpack-forms' ), + 'description' => __( 'The duration in seconds from first user interaction to form submission. Null when the duration is unknown, such as for submissions predating this feature.', 'jetpack-forms' ), 'type' => array( 'integer', 'null' ), 'context' => array( 'view', 'edit', 'embed' ), - 'arg_options' => array( - 'sanitize_callback' => 'absint', - ), + // No sanitize_callback: the field is readonly and sanitized on storage, and + // `absint` would coerce a legitimate null into 0. 'readonly' => true, ); diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 23515d9b141c..315c53533bb4 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -1633,7 +1633,10 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label $r .= ''; } $r .= "\n"; - $r .= "\n"; + // Left empty on purpose: the view script fills this in on submit. An empty + // value is stored as null so "never interacted with" stays distinguishable + // from "filled out in under a second". + $r .= "\n"; $r .= $form->body; if ( $is_multistep ) { diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index 31eb76ac8046..bd668c6ac6c5 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -507,7 +507,7 @@ private function load_from_submission( $post_data, $form, $current_post = null, $this->ip_address = Contact_Form_Plugin::get_ip_address(); $this->country_code = $this->get_country_code_from_ip( $this->ip_address ); $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null; - $this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null; + $this->form_fill_duration = $this->get_computed_form_fill_duration( $post_data ); $this->subject = $this->get_computed_subject( $post_data, $form ); $this->author_data = Feedback_Author::from_submission( $post_data, $form ); $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); @@ -2277,6 +2277,26 @@ private function get_computed_consent( $post_data, $form ) { return false; } + /** + * Gets the computed form fill duration, in seconds. + * + * The value is supplied by the view script as a hidden field. It is left empty when the + * submitter never interacted with the form (or ran without JavaScript), in which case the + * duration is unknown and stored as null rather than as a misleading 0. + * + * @since $$next-version$$ + * + * @param array $post_data The post data from the form submission. + * @return int|null + */ + private function get_computed_form_fill_duration( $post_data ) { + if ( ! isset( $post_data['form_fill_duration'] ) || $post_data['form_fill_duration'] === '' ) { + return null; + } + + return absint( $post_data['form_fill_duration'] ); + } + /** * Gets the computed notification recipients. * diff --git a/projects/packages/forms/src/modules/form/shared.ts b/projects/packages/forms/src/modules/form/shared.ts index d93204cf6858..88e17936ef9f 100644 --- a/projects/packages/forms/src/modules/form/shared.ts +++ b/projects/packages/forms/src/modules/form/shared.ts @@ -9,7 +9,7 @@ const debug = debugFactory( 'jetpack-forms:interactivity' ); const NAMESPACE = 'jetpack/form'; const config = getConfig( NAMESPACE ); -const getForm = ( formHash: string ) => { +export const getForm = ( formHash: string ) => { return document.getElementById( 'jp-form-' + formHash ) as HTMLFormElement | null; }; diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index d5ec499aa016..4486a1fe67b4 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -14,7 +14,7 @@ import { import { validateField, isEmptyValue } from '../../contact-form/js/validate-helper.js'; import { getRating } from '../field-rating/view.js'; import { maybeAddColonToLabel, maybeTransformValue, getImages, getUrl } from './helpers.js'; -import { focusNextInput, submitForm } from './shared.ts'; +import { focusNextInput, getForm, submitForm } from './shared.ts'; // Import field type icons view to register its callbacks. import './field-type-icons-view.js'; @@ -664,6 +664,20 @@ const { state, actions } = store( NAMESPACE, { context.isSubmitting = true; + // Record the fill duration in the DOM before submitting. This has to happen + // outside the `useAjax` branch below: non-AJAX forms submit natively, so the + // value is only sent if it is already on the hidden input by this point. + if ( context.formFirstInteractionTime ) { + const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds. + const durationField = getForm( context.formHash )?.querySelector( + 'input[name="form_fill_duration"]' + ); + + if ( durationField ) { + durationField.value = duration; + } + } + if ( context.useAjax ) { event.preventDefault(); event.stopPropagation(); @@ -672,18 +686,6 @@ const { state, actions } = store( NAMESPACE, { // Capture file preview URLs before submission (blob URLs for images, icon URLs for other files) capturedFilePreviews = captureFilePreviews( context.formHash ); - // Calculate and set the form fill duration before submission - if ( context.formFirstInteractionTime ) { - const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds - const form = document.getElementById( 'jp-form-' + context.formHash ); - if ( form ) { - const durationField = form.querySelector( 'input[name="form_fill_duration"]' ); - if ( durationField ) { - durationField.value = duration; - } - } - } - const { success, error, data, refreshArgs } = yield submitForm( context.formHash ); if ( success ) { diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php index 9e518bb42ddd..c12172e35041 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php @@ -12,6 +12,7 @@ require_once __DIR__ . '/class-utility.php'; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use WorDBless\BaseTestCase; /** @@ -421,4 +422,92 @@ public function test_form_fill_duration_persists_after_save() { $this->assertEquals( 45, $saved_response->get_form_fill_duration(), 'Form fill duration should be 45 seconds' ); $this->assertIsInt( $saved_response->get_form_fill_duration(), 'Form fill duration should be an integer' ); } + + /** + * An empty form_fill_duration means the duration is unknown, and must be stored as null + * rather than as 0 so it stays distinct from a form that really was filled out instantly. + * + * @dataProvider provide_unknown_form_fill_durations + * + * @param array $extra_post_data Submission data to merge in. + * @param string $message Assertion message. + */ + #[DataProvider( 'provide_unknown_form_fill_durations' )] + public function test_form_fill_duration_is_null_when_unknown( $extra_post_data, $message ) { + $form_id = Utility::get_form_id(); + + $_post_data = Utility::get_post_request( + array_merge( + array( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + ), + $extra_post_data + ), + 'g' . $form_id + ); + + $form = new Contact_Form( + array( + 'title' => 'Test Form', + 'description' => 'This is a test form.', + ), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + + $response = Feedback::from_submission( $_post_data, $form ); + $saved_response = Feedback::get( $response->save() ); + + $this->assertNull( $response->get_form_fill_duration(), $message ); + $this->assertNull( $saved_response->get_form_fill_duration(), $message . ' (after save/load)' ); + } + + /** + * Data provider for test_form_fill_duration_is_null_when_unknown. + * + * @return array + */ + public static function provide_unknown_form_fill_durations() { + return array( + 'empty value (no interaction recorded)' => array( + array( 'form_fill_duration' => '' ), + 'An empty duration should be stored as null, not 0', + ), + 'field absent entirely' => array( + array(), + 'A missing duration should be stored as null', + ), + ); + } + + /** + * A real, sub-second fill still records 0 — that is a known duration, not an unknown one. + */ + public function test_form_fill_duration_zero_is_preserved() { + $form_id = Utility::get_form_id(); + + $_post_data = Utility::get_post_request( + array( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + 'form_fill_duration' => '0', + ), + 'g' . $form_id + ); + + $form = new Contact_Form( + array( + 'title' => 'Test Form', + 'description' => 'This is a test form.', + ), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + + $response = Feedback::from_submission( $_post_data, $form ); + $saved_response = Feedback::get( $response->save() ); + + $this->assertSame( 0, $saved_response->get_form_fill_duration(), 'An explicit 0 duration should be preserved as 0, not converted to null' ); + } } From c578b4ed8f2bd5a1166ab3192c5ffde0ed5d60ab Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 4 Aug 2026 16:43:20 -0700 Subject: [PATCH 6/8] Forms: reset the fill timer, validate the submitted duration, and prefix the field --- .../src/contact-form/class-contact-form.php | 4 +- .../forms/src/contact-form/class-feedback.php | 37 +++++- .../forms/src/modules/file-field/view.js | 4 + .../packages/forms/src/modules/form/view.js | 31 +++-- .../Contact_Form_Endpoint_Test.php | 5 + .../php/contact-form/Contact_Form_Test.php | 25 +++++ .../Feedback_Author_Metadata_Test.php | 106 ++++++++++++++++-- .../tests/php/contact-form/class-utility.php | 2 +- 8 files changed, 188 insertions(+), 26 deletions(-) diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 315c53533bb4..1754c22c199d 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -1544,7 +1544,7 @@ public static function parse( $attributes, $content, $context = array() ) { id='contact-form-$id' class='{$container_classes_string}' data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . " - data-wp-on--focusin=\"callbacks.trackFirstInteraction\" + data-wp-on--focusin=\"actions.trackFirstInteraction\" data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\" >\n"; @@ -1636,7 +1636,7 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label // Left empty on purpose: the view script fills this in on submit. An empty // value is stored as null so "never interacted with" stays distinguishable // from "filled out in under a second". - $r .= "\n"; + $r .= "\n"; $r .= $form->body; if ( $is_multistep ) { diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php index bd668c6ac6c5..c5d30b2a2876 100644 --- a/projects/packages/forms/src/contact-form/class-feedback.php +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -52,6 +52,19 @@ class Feedback { */ public const IS_TEST_META_KEY = '_feedback_is_test'; + /** + * Name of the hidden POST field carrying the form fill duration. + * + * Prefixed because submitted fields share one flat POST namespace with author-defined + * fields, whose names a site owner can set by hand. An unprefixed `form_fill_duration` + * field would silently overwrite this one. + * + * @since $$next-version$$ + * + * @var string + */ + public const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration'; + /** * Cache key for the source post IDs list. * @@ -2280,9 +2293,15 @@ private function get_computed_consent( $post_data, $form ) { /** * Gets the computed form fill duration, in seconds. * - * The value is supplied by the view script as a hidden field. It is left empty when the - * submitter never interacted with the form (or ran without JavaScript), in which case the - * duration is unknown and stored as null rather than as a misleading 0. + * The value is supplied by the view script as a hidden field, so it is submitter-controlled + * and cannot be trusted. Anything that is not a plain sequence of digits is treated as + * unknown and stored as null, rather than being coerced into a number that would read as a + * real measurement: `absint()` alone would turn "abc" into 0 (indistinguishable from a + * genuine sub-second fill), "-1" into 1, and a value past PHP_INT_MAX into a float, which + * would contradict the integer type the REST schema advertises. + * + * The value is left empty when the submitter never interacted with the form, or ran without + * JavaScript, which is also an unknown duration. * * @since $$next-version$$ * @@ -2290,11 +2309,19 @@ private function get_computed_consent( $post_data, $form ) { * @return int|null */ private function get_computed_form_fill_duration( $post_data ) { - if ( ! isset( $post_data['form_fill_duration'] ) || $post_data['form_fill_duration'] === '' ) { + if ( ! isset( $post_data[ self::FORM_FILL_DURATION_FIELD ] ) ) { + return null; + } + + $raw = $post_data[ self::FORM_FILL_DURATION_FIELD ]; + + // is_scalar() has to come first so an array-shaped POST does not blow up on the cast. + if ( ! is_scalar( $raw ) || ! ctype_digit( (string) $raw ) ) { return null; } - return absint( $post_data['form_fill_duration'] ); + // Clamp so an abandoned tab left open for days cannot skew aggregates. + return min( (int) $raw, DAY_IN_SECONDS ); } /** diff --git a/projects/packages/forms/src/modules/file-field/view.js b/projects/packages/forms/src/modules/file-field/view.js index a0307367a22d..6632f7c76907 100644 --- a/projects/packages/forms/src/modules/file-field/view.js +++ b/projects/packages/forms/src/modules/file-field/view.js @@ -304,6 +304,10 @@ const { state, actions } = store( NAMESPACE, { */ fileDropped: event => { event.preventDefault(); + // A drop fires no focus event, so the form's `focusin` handler never sees it. + // Without this, dropping a file and submitting would report the fill as starting + // at the submit button rather than at the drop. + jetpackFormStore.actions.trackFirstInteraction(); if ( event.dataTransfer ) { for ( const item of Array.from( event.dataTransfer.items ) ) { if ( item.webkitGetAsEntry()?.isDirectory ) { diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index 4486a1fe67b4..b1d5fc740e0e 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -28,6 +28,9 @@ const NAMESPACE = 'jetpack/form'; const config = getConfig( NAMESPACE ); let errorTimeout = null; +// Must match Feedback::FORM_FILL_DURATION_FIELD in src/contact-form/class-feedback.php. +const FORM_FILL_DURATION_FIELD = 'jetpack_form_fill_duration'; + const updateField = ( fieldId, value, showFieldError = false, validatorCallback = null ) => { const context = getContext(); let field = context.fields[ fieldId ]; @@ -608,10 +611,28 @@ const { state, actions } = store( NAMESPACE, { actions.updateField( context.fieldId, event.target.value, true ); }, + /** + * Start the fill timer on the submitter's first interaction with the form. + * + * Bound to `focusin` on the form wrapper, which covers every focusable control. File + * drag-and-drop fires no focus event, so `jetpack/field-file` calls this directly. + */ + trackFirstInteraction: () => { + const context = getContext(); + + if ( ! context.formFirstInteractionTime ) { + context.formFirstInteractionTime = Date.now(); + } + }, + onFormReset: () => { const context = getContext(); context.fields = []; context.showErrors = false; + // Start the fill timer over. Without this, going back from the success panel and + // filling the form in again would report a duration that also covers the first + // submission and the time spent reading the confirmation. + context.formFirstInteractionTime = null; // Dispatch custom events to reset all fields const formElement = document.getElementById( context.elementId ); @@ -670,7 +691,7 @@ const { state, actions } = store( NAMESPACE, { if ( context.formFirstInteractionTime ) { const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds. const durationField = getForm( context.formHash )?.querySelector( - 'input[name="form_fill_duration"]' + `input[name="${ FORM_FILL_DURATION_FIELD }"]` ); if ( durationField ) { @@ -781,14 +802,6 @@ const { state, actions } = store( NAMESPACE, { registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra ); }, - trackFirstInteraction() { - const context = getContext(); - // Store the first interaction time when user focuses on any form field - if ( ! context.formFirstInteractionTime ) { - context.formFirstInteractionTime = Date.now(); - } - }, - scrollToWrapper() { const context = getContext(); diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php index 3802e737b2e7..1c288a0c24b8 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Endpoint_Test.php @@ -179,6 +179,11 @@ public function test_item_schema() { $this->assertArrayHasKey( 'is_test', $schema_properties ); $this->assertEquals( 'boolean', $schema_properties['is_test']['type'] ); $this->assertArrayHasKey( 'preview_url', $schema_properties ); + $this->assertArrayHasKey( 'form_fill_duration', $schema_properties ); + + // The duration is null whenever it is unknown, so the schema has to allow both. + $this->assertContains( 'integer', $schema_properties['form_fill_duration']['type'] ); + $this->assertContains( 'null', $schema_properties['form_fill_duration']['type'] ); // Verify logged_in_user schema structure $logged_in_user_schema = $schema_properties['logged_in_user']; diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php index 735a9368031a..d91f2ed60c87 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php @@ -1306,6 +1306,31 @@ public function test_token_with_curly_brackets_can_be_replaced() { $this->assertEquals( 'Chicago', $plugin->replace_tokens_with_input( $subject, $field_values ) ); } + /** + * The rendered form must carry the hidden duration input and the focusin binding that + * populates it. The storage-level tests build their POST data by hand, so without this + * the whole feature could be removed from the markup and they would all still pass. + */ + public function test_rendered_form_carries_form_fill_duration_markup() { + $html = do_shortcode( "[contact-form][contact-field label='Name' type='name' required='1'/][/contact-form]" ); + + $this->assertStringContainsString( + "name='" . Feedback::FORM_FILL_DURATION_FIELD . "'", + $html, + 'The rendered form should include the hidden duration input' + ); + $this->assertStringContainsString( + "value=''", + $html, + 'The duration input should render empty so an unrecorded duration stores as null' + ); + $this->assertStringContainsString( + 'data-wp-on--focusin="actions.trackFirstInteraction"', + $html, + 'The form wrapper should bind focusin to start the fill timer' + ); + } + /** * Tests that the field attributes remain the same when no escaping is necessary. * diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php index c12172e35041..9194836dac85 100644 --- a/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Feedback_Author_Metadata_Test.php @@ -394,10 +394,10 @@ public function test_form_fill_duration_persists_after_save() { // Create a form submission with form_fill_duration $_post_data = Utility::get_post_request( array( - 'name' => 'John Doe', - 'email' => 'john@example.com', - 'message' => 'Test message', - 'form_fill_duration' => '45', // 45 seconds + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + Feedback::FORM_FILL_DURATION_FIELD => '45', // 45 seconds ), 'g' . $form_id ); @@ -471,14 +471,102 @@ public function test_form_fill_duration_is_null_when_unknown( $extra_post_data, public static function provide_unknown_form_fill_durations() { return array( 'empty value (no interaction recorded)' => array( - array( 'form_fill_duration' => '' ), + array( Feedback::FORM_FILL_DURATION_FIELD => '' ), 'An empty duration should be stored as null, not 0', ), 'field absent entirely' => array( array(), 'A missing duration should be stored as null', ), + 'non-numeric text' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => 'abc' ), + 'Non-numeric input should be null, not absint()-ed to 0', + ), + 'whitespace only' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => ' ' ), + 'Whitespace should be null, not 0', + ), + 'negative value' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => '-1' ), + 'A negative duration is not meaningful and should be null, not flipped positive', + ), + 'scientific notation' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => '1e10' ), + 'Scientific notation should be null rather than expanded to 10000000000', + ), + 'decimal' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => '1.5' ), + 'A non-integer duration should be null', + ), + 'array-shaped POST' => array( + array( Feedback::FORM_FILL_DURATION_FIELD => array( '1' ) ), + 'An array-shaped value should be null and must not fatal on the cast', + ), + ); + } + + /** + * Entries created before this feature existed have no duration recorded at all. This is the + * case the null design exists for, and it goes through load_from_post() rather than + * from_submission(). + */ + public function test_form_fill_duration_is_null_for_legacy_entries() { + $feedback_id = Utility::create_legacy_feedback(); + $response = Feedback::get( $feedback_id ); + + $this->assertNull( $response->get_form_fill_duration(), 'An entry predating this feature should report a null duration' ); + } + + /** + * A value past PHP_INT_MAX must not become a float — the REST schema advertises + * array( 'integer', 'null' ), and a float would JSON-encode as 9.223372036854776e+18. + */ + public function test_form_fill_duration_rejects_overflow() { + $saved_response = $this->save_submission_with_duration( '-9223372036854775808' ); + + $this->assertNull( $saved_response->get_form_fill_duration(), 'An overflowing value should be null rather than a float' ); + } + + /** + * An implausibly long duration is clamped so a tab left open for days cannot skew aggregates. + */ + public function test_form_fill_duration_is_clamped() { + $saved_response = $this->save_submission_with_duration( (string) ( DAY_IN_SECONDS * 30 ) ); + + $this->assertSame( DAY_IN_SECONDS, $saved_response->get_form_fill_duration(), 'A duration beyond a day should be clamped to DAY_IN_SECONDS' ); + $this->assertIsInt( $saved_response->get_form_fill_duration(), 'The clamped duration should still be an integer' ); + } + + /** + * Saves a submission carrying the given raw duration value and returns the reloaded entry. + * + * @param mixed $raw_duration The raw POST value for the duration field. + * @return Feedback + */ + private function save_submission_with_duration( $raw_duration ) { + $form_id = Utility::get_form_id(); + + $_post_data = Utility::get_post_request( + array( + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + Feedback::FORM_FILL_DURATION_FIELD => $raw_duration, + ), + 'g' . $form_id ); + + $form = new Contact_Form( + array( + 'title' => 'Test Form', + 'description' => 'This is a test form.', + ), + "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]" + ); + + $response = Feedback::from_submission( $_post_data, $form ); + + return Feedback::get( $response->save() ); } /** @@ -489,10 +577,10 @@ public function test_form_fill_duration_zero_is_preserved() { $_post_data = Utility::get_post_request( array( - 'name' => 'John Doe', - 'email' => 'john@example.com', - 'message' => 'Test message', - 'form_fill_duration' => '0', + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'message' => 'Test message', + Feedback::FORM_FILL_DURATION_FIELD => '0', ), 'g' . $form_id ); diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php index 9169a6f9034a..0d6404abac00 100644 --- a/projects/packages/forms/tests/php/contact-form/class-utility.php +++ b/projects/packages/forms/tests/php/contact-form/class-utility.php @@ -175,7 +175,7 @@ public static function get_post_request( $values, $form_id = null, $post_id = 0 $prefix = $form_id ? $form_id : 'g' . $post_id; $post_data = array(); foreach ( $values as $key => $val ) { - if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || strpos( $key, 'form_fill_duration' ) === 0 ) { + if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || $key === Feedback::FORM_FILL_DURATION_FIELD ) { $post_data[ $key ] = $val; } else { $post_data[ $prefix . '-' . $key ] = $val; From 5c103117bc6fc4cbb3c858bc109fefc77242328b Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Tue, 4 Aug 2026 16:55:49 -0700 Subject: [PATCH 7/8] Forms: clear the duration input on reset so a stale value cannot be resubmitted --- projects/packages/forms/src/modules/form/view.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index b1d5fc740e0e..9a4fdf4f8604 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -633,6 +633,16 @@ const { state, actions } = store( NAMESPACE, { // filling the form in again would report a duration that also covers the first // submission and the time spent reading the confirmation. context.formFirstInteractionTime = null; + // Clear the hidden input too. The timer alone is not enough: the write on submit is + // conditional, so a leftover value from the previous submission could otherwise be + // sent again as if it were a fresh measurement. + const durationField = getForm( context.formHash )?.querySelector( + `input[name="${ FORM_FILL_DURATION_FIELD }"]` + ); + + if ( durationField ) { + durationField.value = ''; + } // Dispatch custom events to reset all fields const formElement = document.getElementById( context.elementId ); From 3472e3797534c215f9ba6c900aa6d4e1f29a520a Mon Sep 17 00:00:00 2001 From: Enej Bajgoric Date: Wed, 5 Aug 2026 15:20:29 -0700 Subject: [PATCH 8/8] Forms: write the duration unconditionally and measure it on a monotonic clock --- .../packages/forms/src/modules/form/view.js | 47 +++++++++---------- .../php/contact-form/Contact_Form_Test.php | 10 ++-- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js index 9a4fdf4f8604..c1594c09015a 100644 --- a/projects/packages/forms/src/modules/form/view.js +++ b/projects/packages/forms/src/modules/form/view.js @@ -616,12 +616,16 @@ const { state, actions } = store( NAMESPACE, { * * Bound to `focusin` on the form wrapper, which covers every focusable control. File * drag-and-drop fires no focus event, so `jetpack/field-file` calls this directly. + * + * Uses `performance.now()` rather than `Date.now()`: it is monotonic, so a wall-clock + * step backward mid-fill cannot produce a negative duration. Compared against null + * rather than tested for truthiness, since a legitimate reading can be 0. */ trackFirstInteraction: () => { const context = getContext(); - if ( ! context.formFirstInteractionTime ) { - context.formFirstInteractionTime = Date.now(); + if ( context.formFirstInteractionTime == null ) { + context.formFirstInteractionTime = performance.now(); } }, @@ -631,18 +635,9 @@ const { state, actions } = store( NAMESPACE, { context.showErrors = false; // Start the fill timer over. Without this, going back from the success panel and // filling the form in again would report a duration that also covers the first - // submission and the time spent reading the confirmation. + // submission and the time spent reading the confirmation. The hidden input needs no + // clearing here because the write on submit is unconditional. context.formFirstInteractionTime = null; - // Clear the hidden input too. The timer alone is not enough: the write on submit is - // conditional, so a leftover value from the previous submission could otherwise be - // sent again as if it were a fresh measurement. - const durationField = getForm( context.formHash )?.querySelector( - `input[name="${ FORM_FILL_DURATION_FIELD }"]` - ); - - if ( durationField ) { - durationField.value = ''; - } // Dispatch custom events to reset all fields const formElement = document.getElementById( context.elementId ); @@ -695,18 +690,22 @@ const { state, actions } = store( NAMESPACE, { context.isSubmitting = true; - // Record the fill duration in the DOM before submitting. This has to happen - // outside the `useAjax` branch below: non-AJAX forms submit natively, so the - // value is only sent if it is already on the hidden input by this point. - if ( context.formFirstInteractionTime ) { - const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds. - const durationField = getForm( context.formHash )?.querySelector( - `input[name="${ FORM_FILL_DURATION_FIELD }"]` - ); + // Record the fill duration in the DOM before submitting. This has to happen outside + // the `useAjax` branch below: non-AJAX forms submit natively, so the value is only + // sent if it is already on the hidden input by this point. + // + // The write is unconditional so the input can never carry a value left over from an + // earlier fill — an unknown duration explicitly writes an empty string, which the + // server stores as null. + const durationField = getForm( context.formHash )?.querySelector( + `input[name="${ FORM_FILL_DURATION_FIELD }"]` + ); - if ( durationField ) { - durationField.value = duration; - } + if ( durationField ) { + durationField.value = + context.formFirstInteractionTime == null + ? '' + : Math.round( ( performance.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds. } if ( context.useAjax ) { diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php index d91f2ed60c87..65e400160147 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Test.php @@ -1314,13 +1314,11 @@ public function test_token_with_curly_brackets_can_be_replaced() { public function test_rendered_form_carries_form_fill_duration_markup() { $html = do_shortcode( "[contact-form][contact-field label='Name' type='name' required='1'/][/contact-form]" ); + // Asserted as a pair: an unanchored `value=''` would also match the Name field, which + // renders an empty value of its own, so it would pass even if the duration input + // carried a default. $this->assertStringContainsString( - "name='" . Feedback::FORM_FILL_DURATION_FIELD . "'", - $html, - 'The rendered form should include the hidden duration input' - ); - $this->assertStringContainsString( - "value=''", + "name='" . Feedback::FORM_FILL_DURATION_FIELD . "' value=''", $html, 'The duration input should render empty so an unrecorded duration stores as null' );