Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions src/wp-admin/includes/admin-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
add_action( 'admin_print_scripts', 'print_emoji_detection_script' );
add_action( 'admin_print_scripts', 'print_head_scripts', 20 );
add_action( 'admin_print_footer_scripts', '_wp_footer_scripts' );
add_action( 'admin_print_footer_scripts', 'wp_print_script_data', 21 );
add_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );
add_action( 'admin_print_styles', 'print_emoji_styles' ); // Retained for backwards-compatibility. Unhooked by wp_enqueue_emoji_styles().
add_action( 'admin_print_styles', 'print_admin_styles', 20 );
Expand Down
112 changes: 112 additions & 0 deletions src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -1182,4 +1182,116 @@ protected function get_dependency_warning_message( $handle, $missing_dependency_
implode( ', ', $missing_dependency_handles )
);
}

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot remove this empty line

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fbee29

/**
* Prints data associated with scripts.
*
* The data will be embedded in the page HTML and can be read by scripts on page load.
*
* Data can be associated with a script via the {@see "script_data_{$handle}"} filter.
*
* The data for a script will be serialized as JSON in a script tag with an ID of the
* form `wp-script-data-{$handle}`.
*
* @since 6.8.0
*/
public function print_script_data() {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot instead of a dedicated function and another action, run the filter and print the data script tag as scripts are processed. This can run before an enqueued script is printed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored in commits 16c9b3f and 3edd50e. The filter now runs directly in do_item() as scripts are processed:

  • Removed separate print_script_data() method and wrapper function
  • Removed action hooks from wp_footer and admin_print_footer_scripts
  • Filter is applied during script processing in do_item()
  • Data script tag is output immediately before each script tag
  • Tests updated to verify proper ordering

The implementation is now cleaner and ensures data is available exactly when each script loads.

/*
* Determine JSON encoding flags once, outside the loop.
* The charset won't change during iteration.
*
* This data will be printed as JSON inside a script tag like this:
* <script type="application/json"></script>
*
* A script tag must be closed by a sequence beginning with `</`. It's impossible to
* close a script tag without using `<`. We ensure that `<` is escaped and `/` can
* remain unescaped, so `</script>` will be printed as `\u003C/script>`.
*
* - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E.
* - JSON_UNESCAPED_SLASHES: Don't escape /.
*
* If the page will use UTF-8 encoding, it's safe to print unescaped unicode:
*
* - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`).
* - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when
* JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was
* before PHP 7.1 without this constant. Available as of PHP 7.1.0.
*
* The JSON specification requires encoding in UTF-8, so if the generated HTML page
* is not encoded in UTF-8 then it's not safe to include those literals. They must
* be escaped to avoid encoding issues.
*
* @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements.
* @see https://www.php.net/manual/en/json.constants.php for details on these constants.
* @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing.
*/
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS;
if ( ! is_utf8_charset() ) {
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
}

foreach ( array_unique( $this->queue ) as $handle ) {
/**
* Filters data associated with a given script.
*
* The dynamic portion of the hook name, `$handle`, refers to the script handle.
*
* Scripts may require data that is required for initialization or is essential
* to have immediately available on page load. These are suitable use cases for
* this data.
*
* This is best suited to pass essential data that must be available to the script for
* initialization or immediately on page load. It does not replace the REST API or
* fetching data from the client.
*
* Example:
*
* add_filter(
* 'script_data_my-script-handle',
* function ( array $data ): array {
* $data['myData'] = array(
* 'option' => get_option( 'my_option' ),
* );
* return $data;
* }
* );
*
* If the filter returns no data (an empty array), nothing will be embedded in the page.
*
* The data for a given script, if provided, will be JSON serialized in a script
* tag with an ID of the form `wp-script-data-{$handle}`.
*
* The data can be read on the client with a pattern like this:
*
* Example:
*
* const dataContainer = document.getElementById( 'wp-script-data-my-script-handle' );
* let data = {};
* if ( dataContainer ) {
* try {
* data = JSON.parse( dataContainer.textContent );
* } catch {}
* }
* initMyScriptWithData( data );
*
* @since 6.8.0
*
* @param array $data The data associated with the script.
*/
$data = apply_filters( "script_data_{$handle}", array() );

if ( ! empty( $data ) ) {
wp_print_inline_script_tag(
(string) wp_json_encode(
$data,
$json_encode_flags
),
array(
'type' => 'application/json',
'id' => "wp-script-data-{$handle}",
)
);
}
}
}
}
1 change: 1 addition & 0 deletions src/wp-includes/default-filters.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
add_action( 'wp_head', 'wp_site_icon', 99 );
add_action( 'wp_footer', 'wp_print_speculation_rules' );
add_action( 'wp_footer', 'wp_print_footer_scripts', 20 );
add_action( 'wp_footer', 'wp_print_script_data', 21 );
add_action( 'template_redirect', 'wp_shortlink_header', 11, 0 );
add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' );
add_action( 'init', '_register_core_block_patterns_and_categories' );
Expand Down
17 changes: 17 additions & 0 deletions src/wp-includes/functions.wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,20 @@ function wp_script_is( $handle, $status = 'enqueued' ) {
function wp_script_add_data( $handle, $key, $value ) {
return wp_scripts()->add_data( $handle, $key, $value );
}

/**
* Prints data associated with enqueued scripts.
*
* @since 6.8.0
*
* @see WP_Scripts::print_script_data()
*/
function wp_print_script_data() {
global $wp_scripts;

if ( ! ( $wp_scripts instanceof WP_Scripts ) ) {
return;
}

$wp_scripts->print_script_data();
}
169 changes: 169 additions & 0 deletions tests/phpunit/tests/dependencies/scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -4122,4 +4122,173 @@ public function test_wp_scripts_doing_it_wrong_for_missing_dependencies() {
'Expected _doing_it_wrong() notice to indicate missing dependencies for enqueued script.'
);
}

/**
* Tests that print_script_data outputs JSON script tags.
*
* @covers WP_Scripts::print_script_data
*/
public function test_print_script_data_outputs_json_script_tag() {
wp_enqueue_script( 'test-script', '/test.js', array(), null );

add_filter(
'script_data_test-script',
function ( $data ) {
$data['foo'] = 'bar';
return $data;
}
);

$output = get_echo( 'wp_print_script_data' );

$this->assertStringContainsString( '<script type="application/json" id="wp-script-data-test-script">', $output );
$this->assertStringContainsString( '"foo":"bar"', $output );
}

/**
* Tests that the script_data_{$handle} filter receives an empty array by default.
*
* @covers WP_Scripts::print_script_data
*/
public function test_script_data_filter_receives_empty_array() {
wp_enqueue_script( 'test-script', '/test.js', array(), null );

$filter_called = false;
add_filter(
'script_data_test-script',
function ( $data ) use ( &$filter_called ) {
$filter_called = true;
$this->assertSame( array(), $data );
return $data;
}
);

get_echo( 'wp_print_script_data' );

$this->assertTrue( $filter_called, 'Filter should have been called' );
}

/**
* Tests that the script_data_{$handle} filter doesn't output anything for empty data.
*
* @covers WP_Scripts::print_script_data
*/
public function test_script_data_filter_no_output_for_empty_data() {
wp_enqueue_script( 'test-script', '/test.js', array(), null );

add_filter(
'script_data_test-script',
function ( $data ) {
// Return empty array.
return $data;
}
);

$output = get_echo( 'wp_print_script_data' );

$this->assertSame( '', $output );
}

/**
* Tests that the script_data_{$handle} filter is called for each enqueued script.
*
* @covers WP_Scripts::print_script_data
*/
public function test_script_data_filter_called_for_each_enqueued_script() {
wp_enqueue_script( 'script-1', '/script-1.js', array(), null );
wp_enqueue_script( 'script-2', '/script-2.js', array(), null );

$filter_calls = array();
add_filter(
'script_data_script-1',
function ( $data ) use ( &$filter_calls ) {
$filter_calls[] = 'script-1';
$data['script'] = '1';
return $data;
}
);

add_filter(
'script_data_script-2',
function ( $data ) use ( &$filter_calls ) {
$filter_calls[] = 'script-2';
$data['script'] = '2';
return $data;
}
);

$output = get_echo( 'wp_print_script_data' );

$this->assertSame( array( 'script-1', 'script-2' ), $filter_calls );
$this->assertStringContainsString( 'wp-script-data-script-1', $output );
$this->assertStringContainsString( 'wp-script-data-script-2', $output );
$this->assertStringContainsString( '"script":"1"', $output );
$this->assertStringContainsString( '"script":"2"', $output );
}

/**
* Tests that the script_data_{$handle} filter is only called for enqueued scripts.
*
* @covers WP_Scripts::print_script_data
*/
public function test_script_data_filter_only_called_for_enqueued_scripts() {
wp_register_script( 'registered-only', '/registered-only.js', array(), null );
wp_enqueue_script( 'enqueued', '/enqueued.js', array(), null );

$filter_calls = array();
add_filter(
'script_data_registered-only',
function ( $data ) use ( &$filter_calls ) {
$filter_calls[] = 'registered-only';
return $data;
}
);

add_filter(
'script_data_enqueued',
function ( $data ) use ( &$filter_calls ) {
$filter_calls[] = 'enqueued';
$data['test'] = 'value';
return $data;
}
);

$output = get_echo( 'wp_print_script_data' );

$this->assertSame( array( 'enqueued' ), $filter_calls );
$this->assertStringNotContainsString( 'wp-script-data-registered-only', $output );
$this->assertStringContainsString( 'wp-script-data-enqueued', $output );
}

/**
* Tests that the script_data_{$handle} filter works independently from wp_localize_script.
*
* @covers WP_Scripts::print_script_data
*/
public function test_script_data_filter_independent_from_localize() {
wp_enqueue_script( 'test-script', '/test.js', array(), null );
wp_localize_script( 'test-script', 'myData', array( 'localized' => 'value' ) );

add_filter(
'script_data_test-script',
function ( $data ) {
$data['filtered'] = 'data';
return $data;
}
);

$script_output = get_echo( 'wp_print_scripts' );
$data_output = get_echo( 'wp_print_script_data' );

// Check localized data is in script output as JavaScript.
$this->assertStringContainsString( 'var myData = {"localized":"value"};', $script_output );

// Check filtered data is in data output as JSON.
$this->assertStringContainsString( '<script type="application/json" id="wp-script-data-test-script">', $data_output );
$this->assertStringContainsString( '"filtered":"data"', $data_output );

// Data output should not contain the localized script data.
$this->assertStringNotContainsString( 'myData', $data_output );
$this->assertStringNotContainsString( 'localized', $data_output );
}
}