Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Script Modules: Add server to client data passing #6682

Closed
118 changes: 118 additions & 0 deletions src/wp-includes/class-wp-script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ public function add_hooks() {
add_action( 'admin_print_footer_scripts', array( $this, 'print_import_map' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_enqueued_script_modules' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_preloads' ) );

add_action( 'wp_footer', array( $this, 'print_script_module_data' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_data' ) );
}

/**
Expand Down Expand Up @@ -363,4 +366,119 @@ private function get_src( string $id ): string {

return $src;
}

/**
* Print data associated with Script Modules.
*
* The data will be embedded in the page HTML and can be read by Script Modules on page load.
*
* @since 6.7.0
*
* Data can be associated with a Script Module via the
* {@see "script_module_data_{$module_id}"} filter.
*
* The data for a Script Module will be serialized as JSON in a script tag with an ID of the
* form `wp-script-module-data-{$module_id}`.
*/
public function print_script_module_data(): void {
$modules = array();
foreach ( array_keys( $this->get_marked_for_enqueue() ) as $id ) {
$modules[ $id ] = true;
}
foreach ( array_keys( $this->get_import_map()['imports'] ) as $id ) {
$modules[ $id ] = true;
}

foreach ( array_keys( $modules ) as $module_id ) {
/**
* Filters data associated with a given Script Module.
*
* Script Modules 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.
*
* The dynamic portion of the hook name, `$module_id`, refers to the Script Module ID
* that the data is associated with.
*
* This is best suited to pass essential data that must be available to the module for
* initialization or immediately on page load. It does not replace the REST API or
* fetching data from the client.
*
* @example
* add_filter(
* 'script_module_data_MyScriptModuleID',
* function ( array $data ): array {
* $data['script-needs-this-data'] = 'ok';
* return $data;
* }
* );
Comment on lines +408 to +414
Copy link
Member

Choose a reason for hiding this comment

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

My only remaining question is whether adding a filter is the most ergonomic way to add data to a module. Perhaps there should be a function to make this easier? For example:

wp_script_modules()->add_data( 'MyScriptModuleID', array( 'script-needs-this-data' => 'ok' ) );

Compare with a typical way that scripts expose data to non-module scripts via wp_localize_script():

wp_localize_script( 'my-script-handle', 'MyScript', array( 'script-needs-this-data' => 'ok' ) );

Granted, this use of wp_localize_script() is not ideal since it is a hack of what is intended to be used for l10n. What I think is the better more modern alternative is to use wp_add_inline_script_tag() (which is also worse in some ways since it requires manual serialization):

wp_add_inline_script(
	'my-script-handle',
	sprintf( 'var MyScript = %s;', wp_json_encode( array( 'script-needs-this-data' => 'ok' ) ) ),
	'before'
);

There is also the WP_Scripts::add_data() method (and wp_script_add_data() helper function) which are used to add "data" to script handles, where wp_localize_script() adds this to the extra array key, and wp_add_inline_script() adds to the before and after array keys.

What if there was a similar add_data() method on WP_Script_Modules?

This could still ultimately get filtered, but it seems maybe a bit strange for the filter to be the primary interface to add data to a module.

Copy link
Member

@westonruter westonruter Jun 3, 2024

Choose a reason for hiding this comment

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

For example, the pending data could be stored with the registered script module:

--- a/wp-includes/class-wp-script-modules.php
+++ b/wp-includes/class-wp-script-modules.php
@@ -89,10 +89,21 @@ class WP_Script_Modules {
 				'version'      => $version,
 				'enqueue'      => isset( $this->enqueued_before_registered[ $id ] ),
 				'dependencies' => $dependencies,
+				'data'         => array(),
 			);
 		}
 	}
 
+	/**
+	 * Adds data to be exported as JSON for the Script Module.
+	 *
+	 * @param string $id   Script ID.
+	 * @param array  $data Data.
+	 */
+	public function add_data( string $id, array $data ): void {
+		$this->registered[ $id ]['data'] = $data;
+	}
+
 	/**
 	 * Marks the script module to be enqueued in the page.
 	 *

Copy link
Member Author

Choose a reason for hiding this comment

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

@gziolo brought this up in the Gutenberg PR: WordPress/gutenberg#61658 (comment)

The motivation for using a filter is that it's a simple interface to implement and it seems sufficient. It doesn't require the class to store any additional data or to handle data merging, the filters handle that. As you note, the filter would be compatible with other methods for adding data if there's demand for them in the future.

The patch you shared is likely sufficient, although it doesn't consider data merging. Should data be merged on subsequent ::add_data() calls? Maybe there's an argument to merge or replace, or maybe there are separate ::set_data() and ::add_data() methods. As the PR stands right now, it avoids having to answer those questions.

I don't feel strongly. If you do, let me know and I can explore adding those methods.

Copy link
Member

Choose a reason for hiding this comment

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

I don't feel strongly that they have to be added now, but as the API for the JS-side API is refined to access that data, the PHP-side API could also be refined to set the data.

*
* If the filter returns no data (an empty array), nothing will be embedded in the page.
*
* The data for a given Script Module, if provided, will be JSON serialized in a script
* tag with an ID of the form `wp-script-module-data-{$module_id}`.
*
* The data can be read on the client with a pattern like this:
*
* @example
* const dataContainer = document.getElementById( 'wp-script-module-data-MyScriptModuleID' );
* let data = {};
* if ( dataContainer ) {
* try {
* data = JSON.parse( dataContainer.textContent );
* } catch {}
* }
* initMyScriptModuleWithData( data );
*
* @since 6.7.0
*
* @param array $data The data associated with the Script Module.
*/
$data = apply_filters( "script_module_data_{$module_id}", array() );

if ( is_array( $data ) && array() !== $data ) {
/*
* 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\u00E3`.
*
* - 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;
}

wp_print_inline_script_tag(
wp_json_encode(
$data,
$json_encode_flags
),
array(
'type' => 'application/json',
'id' => "wp-script-module-data-{$module_id}",
)
);
}
}
}
}
207 changes: 207 additions & 0 deletions tests/phpunit/tests/script-modules/wpScriptModules.php
Original file line number Diff line number Diff line change
Expand Up @@ -732,4 +732,211 @@ public function test_wp_print_import_map_has_polyfill_when_modules_registered()

$this->assertSame( 'wp-load-polyfill-importmap', $id );
}

/**
* @ticket 61510
*/
public function test_print_script_module_data_prints_enqueued_module_data() {
$this->script_modules->enqueue( '@test/module', '/example.js' );
add_action(
'script_module_data_@test/module',
function ( $data ) {
$data['foo'] = 'bar';
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

$expected = <<<HTML
<script type="application/json" id="wp-script-module-data-@test/module">
{"foo":"bar"}
</script>

HTML;
$this->assertSame( $expected, $actual );
}

/**
* @ticket 61510
*/
public function test_print_script_module_data_prints_dependency_module_data() {
$this->script_modules->register( '@test/dependency', '/dependency.js' );
$this->script_modules->enqueue( '@test/module', '/example.js', array( '@test/dependency' ) );
add_action(
'script_module_data_@test/dependency',
function ( $data ) {
$data['foo'] = 'bar';
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

$expected = <<<HTML
<script type="application/json" id="wp-script-module-data-@test/dependency">
{"foo":"bar"}
</script>

HTML;
$this->assertSame( $expected, $actual );
}

/**
* @ticket 61510
*/
public function test_print_script_module_data_does_not_print_nondependency_module_data() {
$this->script_modules->register( '@test/other', '/dependency.js' );
$this->script_modules->enqueue( '@test/module', '/example.js' );
add_action(
'script_module_data_@test/other',
function ( $data ) {
$data['foo'] = 'bar';
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

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

/**
* @ticket 61510
*/
public function test_print_script_module_data_does_not_print_empty_data() {
$this->script_modules->enqueue( '@test/module', '/example.js' );
add_action(
'script_module_data_@test/module',
function ( $data ) {
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

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

/**
* @ticket 61510
*
* @dataProvider data_special_chars_script_encoding
* @param string $input Raw input string.
* @param string $expected Expected output string.
* @param string $charset Blog charset option.
*/
public function test_print_script_module_data_encoding( $input, $expected, $charset ) {
add_filter(
'pre_option_blog_charset',
function () use ( $charset ) {
return $charset;
}
);

$this->script_modules->enqueue( '@test/module', '/example.js' );
add_action(
'script_module_data_@test/module',
function ( $data ) use ( $input ) {
$data[''] = $input;
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

$expected = <<<HTML
<script type="application/json" id="wp-script-module-data-@test/module">
{"":"{$expected}"}
</script>

HTML;

$this->assertSame( $expected, $actual );
}

/**
* Data provider.
*
* @return array
*/
public static function data_special_chars_script_encoding(): array {
return array(
// UTF-8
'Solidus' => array( '/', '/', 'UTF-8' ),
'Double quote' => array( '"', '\\"', 'UTF-8' ),
'Single quote' => array( '\'', '\'', 'UTF-8' ),
'Less than' => array( '<', '\u003C', 'UTF-8' ),
'Greater than' => array( '>', '\u003E', 'UTF-8' ),
'Ampersand' => array( '&', '&', 'UTF-8' ),
'Newline' => array( "\n", "\\n", 'UTF-8' ),
'Tab' => array( "\t", "\\t", 'UTF-8' ),
'Form feed' => array( "\f", "\\f", 'UTF-8' ),
'Carriage return' => array( "\r", "\\r", 'UTF-8' ),
'Line separator' => array( "\u{2028}", "\u{2028}", 'UTF-8' ),
'Paragraph separator' => array( "\u{2029}", "\u{2029}", 'UTF-8' ),

/*
* The following is the Flag of England emoji
* PHP: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}"
*/
'Flag of england' => array( '🏴󠁧󠁢󠁥󠁮󠁧󠁿', '🏴󠁧󠁢󠁥󠁮󠁧󠁿', 'UTF-8' ),
'Malicious script closer' => array( '</script>', '\u003C/script\u003E', 'UTF-8' ),
'Entity-encoded malicious script closer' => array( '&lt;/script&gt;', '&lt;/script&gt;', 'UTF-8' ),

// Non UTF-8
'Solidus' => array( '/', '/', 'iso-8859-1' ),
'Less than' => array( '<', '\u003C', 'iso-8859-1' ),
'Greater than' => array( '>', '\u003E', 'iso-8859-1' ),
'Ampersand' => array( '&', '&', 'iso-8859-1' ),
'Newline' => array( "\n", "\\n", 'iso-8859-1' ),
'Tab' => array( "\t", "\\t", 'iso-8859-1' ),
'Form feed' => array( "\f", "\\f", 'iso-8859-1' ),
'Carriage return' => array( "\r", "\\r", 'iso-8859-1' ),
'Line separator' => array( "\u{2028}", "\u2028", 'iso-8859-1' ),
'Paragraph separator' => array( "\u{2029}", "\u2029", 'iso-8859-1' ),
/*
* The following is the Flag of England emoji
* PHP: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}"
*/
'Flag of england' => array( '🏴󠁧󠁢󠁥󠁮󠁧󠁿', "\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f", 'iso-8859-1' ),
'Malicious script closer' => array( '</script>', '\u003C/script\u003E', 'iso-8859-1' ),
'Entity-encoded malicious script closer' => array( '&lt;/script&gt;', '&lt;/script&gt;', 'iso-8859-1' ),

);
}

/**
* @ticket 61510
*
* @dataProvider data_invalid_script_module_data
* @param mixed $data Data to return in filter.
*/
public function test_print_script_module_data_does_not_print_invalid_data( $data ) {
$this->script_modules->enqueue( '@test/module', '/example.js' );
add_action(
'script_module_data_@test/module',
function ( $_ ) use ( $data ) {
return $data;
}
);

$actual = get_echo( array( $this->script_modules, 'print_script_module_data' ) );

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

/**
* Data provider.
*
* @return array
*/
public static function data_invalid_script_module_data(): array {
return array(
'null' => array( null ),
'stdClass' => array( new stdClass() ),
'number 1' => array( 1 ),
'string' => array( 'string' ),
);
}
}
Loading