From c4c5d1368e86cf4142e674f64baeadde912bdbf5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Oct 2018 11:37:36 -0700 Subject: [PATCH 01/25] Rename error-response-handling to navigation-routing --- .jshintignore | 2 +- amp/class-amp-service-worker.php | 2 +- pwa.php | 2 +- wp-includes/class-wp-service-workers.php | 8 ++++---- ...-service-worker-navigation-routing-component.php} | 12 ++++++------ .../class-wp-service-worker-fonts-integration.php | 2 +- ...dling.js => service-worker-navigation-routing.js} | 0 7 files changed, 14 insertions(+), 14 deletions(-) rename wp-includes/components/{class-wp-service-worker-error-response-component.php => class-wp-service-worker-navigation-routing-component.php} (93%) rename wp-includes/js/{service-worker-error-response-handling.js => service-worker-navigation-routing.js} (100%) diff --git a/.jshintignore b/.jshintignore index f34ecdf82..9f2656a55 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,3 +1,3 @@ wp-includes/js/service-worker.js -wp-includes/js/service-worker-error-response-handling.js +wp-includes/js/service-worker-navigation-routing.js wp-includes/js/service-worker-precaching.js diff --git a/amp/class-amp-service-worker.php b/amp/class-amp-service-worker.php index 4862fc4c7..40ce1f49e 100644 --- a/amp/class-amp-service-worker.php +++ b/amp/class-amp-service-worker.php @@ -110,7 +110,7 @@ public function add_image_runtime_caching( $service_workers ) { '/wp-content/.*\.(?:png|gif|jpg|jpeg|svg|webp)(\?.*)?$', array( 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST, - 'cacheName' => 'images', + 'cacheName' => 'images', // @todo This needs to get the proper prefix in JS. 'plugins' => array( 'cacheableResponse' => array( 'statuses' => array( 0, 200 ), diff --git a/pwa.php b/pwa.php index ca9b11077..e74aa8dd6 100644 --- a/pwa.php +++ b/pwa.php @@ -52,7 +52,7 @@ /** WP_Service_Worker_Component Implementation Classes */ require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-configuration-component.php'; -require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-error-response-component.php'; +require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-navigation-routing-component.php'; require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-precaching-routes-component.php'; require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-precaching-routes.php'; require_once PWA_PLUGIN_DIR . '/wp-includes/components/class-wp-service-worker-caching-routes-component.php'; diff --git a/wp-includes/class-wp-service-workers.php b/wp-includes/class-wp-service-workers.php index 2893d4856..850c8cf08 100644 --- a/wp-includes/class-wp-service-workers.php +++ b/wp-includes/class-wp-service-workers.php @@ -57,10 +57,10 @@ class WP_Service_Workers implements WP_Service_Worker_Registry_Aware { */ public function __construct() { $components = array( - 'configuration' => new WP_Service_Worker_Configuration_Component(), - 'error_response' => new WP_Service_Worker_Error_Response_Component(), - 'precaching_routes' => new WP_Service_Worker_Precaching_Routes_Component(), - 'caching_routes' => new WP_Service_Worker_Caching_Routes_Component(), + 'configuration' => new WP_Service_Worker_Configuration_Component(), + 'navigation_routing' => new WP_Service_Worker_Navigation_Routing_Component(), + 'precaching_routes' => new WP_Service_Worker_Precaching_Routes_Component(), + 'caching_routes' => new WP_Service_Worker_Caching_Routes_Component(), ); $this->scripts = new WP_Service_Worker_Scripts( $components ); diff --git a/wp-includes/components/class-wp-service-worker-error-response-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php similarity index 93% rename from wp-includes/components/class-wp-service-worker-error-response-component.php rename to wp-includes/components/class-wp-service-worker-navigation-routing-component.php index f728960c6..9d818f00f 100644 --- a/wp-includes/components/class-wp-service-worker-error-response-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -1,16 +1,16 @@ register( - 'wp-error-response', + 'wp-navigation-routing', array( 'src' => array( $this, 'get_script' ), 'deps' => array( 'wp-base-config' ), @@ -139,12 +139,12 @@ public function get_priority() { } /** - * Get script for handling of error responses when the user is offline or when there is an internal server error. + * Get script for routing navigation requests. * * @return string Script. */ public function get_script() { - $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-error-response-handling.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-navigation-routing.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $script = preg_replace( '#/\*\s*global.+?\*/#', '', $script ); return str_replace( diff --git a/wp-includes/integrations/class-wp-service-worker-fonts-integration.php b/wp-includes/integrations/class-wp-service-worker-fonts-integration.php index dd85d7908..92d23f572 100644 --- a/wp-includes/integrations/class-wp-service-worker-fonts-integration.php +++ b/wp-includes/integrations/class-wp-service-worker-fonts-integration.php @@ -24,7 +24,7 @@ public function register( WP_Service_Worker_Scripts $scripts ) { '^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/(.*)', array( 'strategy' => WP_Service_Worker_Caching_Routes::STRATEGY_CACHE_FIRST, - 'cacheName' => 'googleapis', + 'cacheName' => 'googleapis', // @todo This needs to get the proper prefix in JS. 'plugins' => array( 'cacheableResponse' => array( 'statuses' => array( 0, 200 ), diff --git a/wp-includes/js/service-worker-error-response-handling.js b/wp-includes/js/service-worker-navigation-routing.js similarity index 100% rename from wp-includes/js/service-worker-error-response-handling.js rename to wp-includes/js/service-worker-navigation-routing.js From 7b8e0bf0ba72fccfbdae805bae67f506370e1a93 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 11 Oct 2018 15:58:03 -0700 Subject: [PATCH 02/25] Adopt streaming logic first prototyped in AMP plugin --- .phpcs.xml.dist | 7 ++ wp-includes/class-wp.php | 1 + ...ce-worker-navigation-routing-component.php | 111 +++++++++++++++++- wp-includes/default-filters.php | 1 + .../js/service-worker-navigation-routing.js | 29 ++++- .../js/service-worker-stream-combiner.js | 33 ++++++ wp-includes/service-workers.php | 101 ++++++++++++++++ 7 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 wp-includes/js/service-worker-stream-combiner.js diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 22a0eb829..e41fc2b7e 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -24,6 +24,13 @@ bin/* + + + + + + + diff --git a/wp-includes/class-wp.php b/wp-includes/class-wp.php index 8a1a99a74..725471f1d 100644 --- a/wp-includes/class-wp.php +++ b/wp-includes/class-wp.php @@ -17,5 +17,6 @@ function pwa_add_error_template_query_var() { global $wp; $wp->add_query_var( 'wp_error_template' ); $wp->add_query_var( WP_Service_Workers::QUERY_VAR ); + $wp->add_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); } add_action( 'init', 'pwa_add_error_template_query_var' ); diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 9d818f00f..48d65078b 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -8,10 +8,35 @@ /** * Class representing the service worker core component for handling navigation requests. * + * @todo The component system needs to be instantiated even if the service worker is not being served. * @since 0.2 */ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worker_Component { + /** + * Query var for requesting a stream fragment. + * + * @since 0.2 + * @var string + */ + const STREAM_FRAGMENT_QUERY_VAR = 'wp_stream_fragment'; + + /** + * Slug used to identify whether a theme supports service worker streaming. + * + * @since 0.2 + * @var string + */ + const STREAM_THEME_SUPPORT = 'service_worker_streaming'; + + /** + * ID for the stream boundary element. + * + * @since 0.2 + * @var string + */ + const STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID = 'wp-stream-fragment-boundary'; + /** * Internal storage for replacements to make in the error response handling script. * @@ -20,6 +45,53 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ protected $replacements = array(); + /** + * Add loading indicator for responses streamed from the service worker. + * + * This this function should generally be called at the end of a theme's header.php template. + * A theme that uses this must also declare 'streaming' among the amp theme support. + * This element is also used to demarcate the header (head) from the body (tail). + * + * @since 2.0 + * @todo Consider using progress element instead? + * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. + */ + public static function print_stream_boundary() { + if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Failed to add "service_worker_streaming" theme support.', 'pwa' ), '0.2' ); + return; + } + $stream_fragment = get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); + if ( ! $stream_fragment ) { + return; + } + + printf( + '
%s
', + esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ), + esc_html__( 'Loading...', 'pwa' ) + ); + + // Short-circuit the response when requesting the header since there is nothing left to stream. + if ( 'header' === $stream_fragment ) { + exit; + } + } + + /** + * Filter the title for the streaming header. + * + * @since 0.2 + * @param string $title Title. + * @return string Title. + */ + public static function filter_title_for_streaming_header( $title ) { + if ( current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ) ) { + $title = __( 'Loading...', 'pwa' ); + } + return $title; + } + /** * Adds the component functionality to the service worker. * @@ -115,15 +187,48 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $scripts->precaching_routes()->register( $server_error_precache_entry['url'], isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null ); } + // Streaming. + $theme_supports_streaming = current_theme_supports( self::STREAM_THEME_SUPPORT ); + $streaming_header_precache_entry = null; + if ( $theme_supports_streaming ) { + $header_template_file = locate_template( array( 'header.php' ) ); + $streaming_header_precache_entry = array( + 'url' => add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'header', home_url( '/' ) ), + 'revision' => $revision . ';' . md5( $header_template_file . file_get_contents( $header_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + ); + + /** + * Filters what is precached to serve as the streaming header. + * + * @since 0.2 + * + * @param array|false $entry { + * Offline error precache entry. + * + * @type string $url URL to streaming header fragment. + * @type string $revision Revision for the entry. Care must be taken to keep this updated based on the content that is output before the stream boundary. + * } + */ + $streaming_header_precache_entry = apply_filters( 'wp_streaming_header_precache_entry', $streaming_header_precache_entry ); + + if ( $streaming_header_precache_entry ) { + $scripts->precaching_routes()->register( $streaming_header_precache_entry['url'], isset( $streaming_header_precache_entry['revision'] ) ? $streaming_header_precache_entry['revision'] : null ); + } else { + $theme_supports_streaming = false; + } + } + $blacklist_patterns = array(); if ( ! is_admin() ) { $blacklist_patterns[] = '^' . preg_quote( untrailingslashit( wp_parse_url( admin_url(), PHP_URL_PATH ) ), '/' ) . '($|\?.*|/.*)'; } $this->replacements = array( - 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, - 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, - 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), + 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, + 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, + 'STREAM_HEADER_FRAGMENT_URL' => isset( $streaming_header_precache_entry['url'] ) ? wp_service_worker_json_encode( $streaming_header_precache_entry['url'] ) : null, + 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), + 'THEME_SUPPORTS_STREAMING' => $theme_supports_streaming, ); } diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index d89a1d04a..44c8413fb 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -18,6 +18,7 @@ add_action( 'parse_query', 'wp_hide_admin_bar_offline' ); add_action( 'wp_head', 'wp_add_error_template_no_robots' ); +add_filter( 'pre_get_document_title', 'WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header' ); add_action( 'error_head', 'wp_add_error_template_no_robots' ); add_action( 'wp_default_service_workers', 'wp_default_service_workers' ); diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 4c4478eb7..b5789b68e 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -1,4 +1,4 @@ -/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, BLACKLIST_PATTERNS */ +/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, BLACKLIST_PATTERNS */ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute( async function ( { event } ) { @@ -52,9 +52,30 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR } } - return fetch( event.request ) - .then( handleResponse ) - .catch( sendOfflineResponse ); + const themeSupportsStreaming = THEME_SUPPORTS_STREAMING; + if ( themeSupportsStreaming ) { + const streamHeaderFragmentURL = STREAM_HEADER_FRAGMENT_URL; + const precacheStrategy = wp.serviceWorker.strategies.cacheFirst({ + cacheName: wp.serviceWorker.core.cacheNames.precache, + }); + + wp.serviceWorker.streams.strategy([ + () => precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode. + fetch( event.request ), // @todo Need to amend event.request.url with wp_service_worker_stream_fragment=body? + // async ({event, url}) => { + // event.request.url.searchParams.set( 'wp_service_worker_stream_fragment', 'body' ); + // const tag = url.searchParams.get('tag') || DEFAULT_TAG; + // const listResponse = await apiStrategy.makeRequest(...); + // const data = await listResponse.json(); + // return templates.index(tag, data.items); + // }, + ]); + + } else { + return fetch( event.request ) + .then( handleResponse ) + .catch( sendOfflineResponse ); + } }, { blacklist: BLACKLIST_PATTERNS.map( ( pattern ) => new RegExp( pattern ) ) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js new file mode 100644 index 000000000..22659026c --- /dev/null +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -0,0 +1,33 @@ +"use strict"; +/* This JS file will be added as an inline script in a stream header fragment response. */ + +/** + * Apply the stream body data to the stream header. + * + * @param {Array} data Data. + * @param {Array} data.head_nodes - Nodes in HEAD. + * @param {Object} data.body_attributes - Attributes on body. + */ +function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ + var removedElements, i, element; + + /* @todo Take the data and actually apply the changes. */ + for ( i = 0; i < data.head_nodes.length; i++ ) { + if ( 'title' === data.head_nodes[ i ][ 0 ] ) { + document.title = data.head_nodes[ i ][ 2 ]; // This should be crafted for actual + } + } + + /* Purge all traces of the stream combination logic to ensure the AMP validator doesn't complain at runtime. */ + removedElements = [ + 'wp-stream-fragment-boundary', + 'wp-stream-combine-function', + 'wp-stream-combine-call' + ]; + for ( i = 0; i < removedElements.length; i++ ) { + element = document.getElementById( removedElements[ i ] ); + if ( element ) { + element.parentNode.removeChild( element ); + } + } +} diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php index 4a0f231f3..b6bbce351 100644 --- a/wp-includes/service-workers.php +++ b/wp-includes/service-workers.php @@ -318,3 +318,104 @@ function wp_disable_script_concatenation() { $concatenate_scripts = rest_sanitize_boolean( $_GET['wp_concatenate_scripts'] ); // WPCS: csrf ok, override ok. } } + +/** + * Prepare stream fragment response. + * + * @since 0.2 + * @todo Hook this up to work in non-AMP responses. + * + * @param DOMDocument $dom Document. + * @param string $fragment_name Fragment name. + * @return string|WP_Error Response fragment string, or WP_Error. + */ +function wp_prepare_stream_fragment_response( $dom, $fragment_name ) { + $boundary_element = $dom->getElementById( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ); + if ( ! $boundary_element ) { + return new WP_Error( 'no_fragment_boundary' ); + } + + $boundary_comment_text = 'WP_STREAM_FRAGMENT_BOUNDARY'; + $search = ""; + + // Obtain header fragment. + if ( 'header' === $fragment_name ) { + $serialized = "\n"; + $comment = $dom->createComment( $boundary_comment_text ); + $boundary_element->parentNode->insertBefore( $comment, $boundary_element->nextSibling ); + $serialized .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); + $token_pos = strpos( $serialized, $search ); + if ( false === $token_pos ) { + return new WP_Error( 'fragment_boundary_not_found' ); + } + $response = substr( $serialized, 0, $token_pos ); + $response .= sprintf( + '', + file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore + ); + return $response; + } + + // Obtain body fragment. + $data = array( + // @todo Add root_attributes? + 'head_nodes' => array(), + 'body_attributes' => array(), + ); + $head = $dom->getElementsByTagName( 'head' )->item( 0 ); + if ( ! $head ) { + return new WP_Error( 'no_head' ); + } + foreach ( $head->childNodes as $node ) { + if ( $node instanceof DOMElement ) { + if ( 'noscript' === $node->nodeName ) { + continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated. + } + $element = array( + $node->nodeName, + null, + ); + if ( $node->hasAttributes() ) { + $element[1] = array(); + foreach ( $node->attributes as $attribute ) { + $element[1][ $attribute->nodeName ] = $attribute->nodeValue; + } + } + if ( $node->firstChild instanceof DOMText ) { + $element[] = $node->firstChild->nodeValue; + } + $data['head_nodes'][] = $element; + } elseif ( $node instanceof DOMComment ) { + $data['head_nodes'][] = array( + '#comment', + $node->nodeValue, + ); + } + } + + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $body ) { + return new WP_Error( 'no_body' ); + } + foreach ( $body->attributes as $attribute ) { + $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; + } + + // @todo Also obtain classes used in nav menus. + $boundary_element->parentNode->insertBefore( $dom->createComment( $boundary_comment_text ), $boundary_element ); + $boundary_element->parentNode->removeChild( $boundary_element ); // Only relevant to serve in the header fragment. + $serialized = AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); + $token_pos = strpos( $serialized, $search ); + if ( false === $token_pos ) { + return new WP_Error( 'fragment_boundary_not_found' ); + } + + $response = sprintf( + '', + wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. + ); + + $response .= substr( $serialized, $token_pos + strlen( $search ) ); + + return $response; +} From fcec82af3479b8302116642be20bfad6d197b043 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Oct 2018 11:19:34 -0700 Subject: [PATCH 03/25] Hook up streams --- .eslintrc | 2 +- ...ce-worker-navigation-routing-component.php | 13 ++++++---- .../js/service-worker-navigation-routing.js | 25 +++++++++++-------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/.eslintrc b/.eslintrc index cceeffc6b..6b468741f 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,6 @@ "es6": true }, "parserOptions": { - "ecmaVersion": 8 + "ecmaVersion": 2018 } } diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 48d65078b..5d308ddcd 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -213,6 +213,8 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { if ( $streaming_header_precache_entry ) { $scripts->precaching_routes()->register( $streaming_header_precache_entry['url'], isset( $streaming_header_precache_entry['revision'] ) ? $streaming_header_precache_entry['revision'] : null ); + + add_filter( 'wp_service_worker_navigation_preload', '__return_false' ); // Navigation preload and streaming don't mix! } else { $theme_supports_streaming = false; } @@ -224,11 +226,12 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { } $this->replacements = array( - 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, - 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, - 'STREAM_HEADER_FRAGMENT_URL' => isset( $streaming_header_precache_entry['url'] ) ? wp_service_worker_json_encode( $streaming_header_precache_entry['url'] ) : null, - 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), - 'THEME_SUPPORTS_STREAMING' => $theme_supports_streaming, + 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, + 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, + 'STREAM_HEADER_FRAGMENT_URL' => isset( $streaming_header_precache_entry['url'] ) ? wp_service_worker_json_encode( $streaming_header_precache_entry['url'] ) : null, + 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), + 'THEME_SUPPORTS_STREAMING' => $theme_supports_streaming, + 'STREAM_HEADER_FRAGMENT_QUERY_VAR' => wp_json_encode( self::STREAM_FRAGMENT_QUERY_VAR ), ); } diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index b5789b68e..4a853e932 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -1,4 +1,8 @@ -/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, BLACKLIST_PATTERNS */ +/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */ + +if ( THEME_SUPPORTS_STREAMING ) { + wp.serviceWorker.streams.isSupported(); // Make sure importScripts happens during SW installation. +} wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute( async function ( { event } ) { @@ -59,18 +63,17 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR cacheName: wp.serviceWorker.core.cacheNames.precache, }); - wp.serviceWorker.streams.strategy([ - () => precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode. - fetch( event.request ), // @todo Need to amend event.request.url with wp_service_worker_stream_fragment=body? - // async ({event, url}) => { - // event.request.url.searchParams.set( 'wp_service_worker_stream_fragment', 'body' ); - // const tag = url.searchParams.get('tag') || DEFAULT_TAG; - // const listResponse = await apiStrategy.makeRequest(...); - // const data = await listResponse.json(); - // return templates.index(tag, data.items); - // }, + const url = new URL( event.request.url ); + url.searchParams.append( STREAM_HEADER_FRAGMENT_QUERY_VAR, 'body' ); + const request = new Request( url.toString(), {...event.request} ); + + const stream = wp.serviceWorker.streams.concatenateToResponse([ + precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode. + fetch( request ), ]); + // @todo Handle error case. + return handleResponse( stream.response ); } else { return fetch( event.request ) .then( handleResponse ) From 6ae18b21f5ac5e830c7415d7897cd4b35adbfb2b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Oct 2018 12:21:33 -0700 Subject: [PATCH 04/25] Add 500/offline error handling to streams --- ...ce-worker-navigation-routing-component.php | 19 ++++++++++++++++--- .../js/service-worker-navigation-routing.js | 19 +++++++++---------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 5d308ddcd..bcd0eeef9 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -103,6 +103,8 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $template = get_template(); $stylesheet = get_stylesheet(); + $theme_supports_streaming = current_theme_supports( self::STREAM_THEME_SUPPORT ); + $revision = sprintf( '%s-v%s', $template, wp_get_theme( $template )->Version ); if ( $template !== $stylesheet ) { $revision .= sprintf( ';%s-v%s', $stylesheet, wp_get_theme( $stylesheet )->Version ); @@ -182,13 +184,24 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { if ( $offline_error_precache_entry ) { $scripts->precaching_routes()->register( $offline_error_precache_entry['url'], isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null ); + if ( $theme_supports_streaming ) { + $scripts->precaching_routes()->register( + add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ), + isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null + ); + } } if ( $server_error_precache_entry ) { $scripts->precaching_routes()->register( $server_error_precache_entry['url'], isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null ); + if ( $theme_supports_streaming ) { + $scripts->precaching_routes()->register( + add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ), + isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null + ); + } } // Streaming. - $theme_supports_streaming = current_theme_supports( self::STREAM_THEME_SUPPORT ); $streaming_header_precache_entry = null; if ( $theme_supports_streaming ) { $header_template_file = locate_template( array( 'header.php' ) ); @@ -215,8 +228,6 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $scripts->precaching_routes()->register( $streaming_header_precache_entry['url'], isset( $streaming_header_precache_entry['revision'] ) ? $streaming_header_precache_entry['revision'] : null ); add_filter( 'wp_service_worker_navigation_preload', '__return_false' ); // Navigation preload and streaming don't mix! - } else { - $theme_supports_streaming = false; } } @@ -227,7 +238,9 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $this->replacements = array( 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, + 'ERROR_OFFLINE_BODY_FRAGMENT_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ) ) : null, 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, + 'ERROR_500_BODY_FRAGMENT_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ) ) : null, 'STREAM_HEADER_FRAGMENT_URL' => isset( $streaming_header_precache_entry['url'] ) ? wp_service_worker_json_encode( $streaming_header_precache_entry['url'] ) : null, 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), 'THEME_SUPPORTS_STREAMING' => $theme_supports_streaming, diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 4a853e932..59d4823ff 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -1,8 +1,6 @@ -/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */ +/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, ERROR_500_BODY_FRAGMENT_URL, ERROR_OFFLINE_BODY_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */ -if ( THEME_SUPPORTS_STREAMING ) { - wp.serviceWorker.streams.isSupported(); // Make sure importScripts happens during SW installation. -} +const isStreamingResponses = THEME_SUPPORTS_STREAMING && wp.serviceWorker.streams.isSupported(); wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute( async function ( { event } ) { @@ -34,11 +32,11 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR channel.close(); }, 30 * 1000 ); - return caches.match( ERROR_500_URL ); + return caches.match( isStreamingResponses ? ERROR_500_BODY_FRAGMENT_URL : ERROR_500_URL ); }; const sendOfflineResponse = () => { - return caches.match( ERROR_OFFLINE_URL ); + return caches.match( isStreamingResponses ? ERROR_OFFLINE_BODY_FRAGMENT_URL : ERROR_OFFLINE_URL ); }; /* @@ -56,8 +54,7 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR } } - const themeSupportsStreaming = THEME_SUPPORTS_STREAMING; - if ( themeSupportsStreaming ) { + if ( isStreamingResponses ) { const streamHeaderFragmentURL = STREAM_HEADER_FRAGMENT_URL; const precacheStrategy = wp.serviceWorker.strategies.cacheFirst({ cacheName: wp.serviceWorker.core.cacheNames.precache, @@ -69,11 +66,13 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR const stream = wp.serviceWorker.streams.concatenateToResponse([ precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode. - fetch( request ), + fetch( request ) + .then( handleResponse ) + .catch( sendOfflineResponse ), ]); // @todo Handle error case. - return handleResponse( stream.response ); + return stream.response; } else { return fetch( event.request ) .then( handleResponse ) From 348840fe2d084ed66ae058efebc8f534d823e547 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Oct 2018 17:44:26 -0700 Subject: [PATCH 05/25] WIP: DOM Diffing --- .jshintignore | 1 + ...ce-worker-navigation-routing-component.php | 10 +- .../js/service-worker-stream-combiner.js | 154 +++++++++++++++++- 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/.jshintignore b/.jshintignore index 9f2656a55..032baa156 100644 --- a/.jshintignore +++ b/.jshintignore @@ -1,3 +1,4 @@ wp-includes/js/service-worker.js wp-includes/js/service-worker-navigation-routing.js wp-includes/js/service-worker-precaching.js +wp-includes/js/service-worker-stream-combiner.js diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index bcd0eeef9..db9a03960 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -104,6 +104,10 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $stylesheet = get_stylesheet(); $theme_supports_streaming = current_theme_supports( self::STREAM_THEME_SUPPORT ); + $stream_combiner_revision = ''; + if ( $theme_supports_streaming ) { + $stream_combiner_revision = md5( file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) ); + } $revision = sprintf( '%s-v%s', $template, wp_get_theme( $template )->Version ); if ( $template !== $stylesheet ) { @@ -187,7 +191,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { if ( $theme_supports_streaming ) { $scripts->precaching_routes()->register( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ), - isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null + ( isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : '' ) . $stream_combiner_revision ); } } @@ -196,7 +200,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { if ( $theme_supports_streaming ) { $scripts->precaching_routes()->register( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ), - isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null + ( isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : '' ) . $stream_combiner_revision ); } } @@ -207,7 +211,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $header_template_file = locate_template( array( 'header.php' ) ); $streaming_header_precache_entry = array( 'url' => add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'header', home_url( '/' ) ), - 'revision' => $revision . ';' . md5( $header_template_file . file_get_contents( $header_template_file ) ), // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + 'revision' => $revision . ';' . md5( $header_template_file . file_get_contents( $header_template_file ) ) . $stream_combiner_revision, // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents ); /** diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 22659026c..48fd76e40 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -1,5 +1,6 @@ "use strict"; /* This JS file will be added as an inline script in a stream header fragment response. */ +/* This file currently uses JS features which are compatible with Chrome 40 (Googlebot). */ /** * Apply the stream body data to the stream header. @@ -9,23 +10,164 @@ * @param {Object} data.body_attributes - Attributes on body. */ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ - var removedElements, i, element; + var node, nextNode, nodeData, refNode, elements; - /* @todo Take the data and actually apply the changes. */ + // Keep track of the elements we matched already so we don't keep updating the same element. + const alreadyMatchedElements = new WeakSet(); + + const isElementMatchingData = ( element, newElementData ) => { + if ( element.nodeName.toLowerCase() !== newElementData[ 0 ] ) { + return false; + } + const elementAttributes = Array.prototype.map.call( element.attributes, ( attribute ) => { + return attribute.nodeName + '=' + attribute.nodeValue; + } ).sort().join( ';' ); + + const dataAttributes = ! newElementData[ 1 ] ? '' : Object.entries( newElementData[ 1 ] ).map( ( [ key, value ] ) => { + return key + '=' + value; + } ).sort().join( ';' ); + + if ( elementAttributes !== dataAttributes ) { + return false; + } + + if ( 'undefined' === typeof newElementData[ 2 ] ) { + return ! element.firstChild; + } else { + return element.firstChild === newElementData[ 2 ]; + } + }; + + const missingNodeData = []; + headNodeLoop: + for ( const nodeData of data.head_nodes ) { + for ( const headChild of document.head.children ) { + if ( isElementMatchingData( headChild, nodeData ) ) { + alreadyMatchedElements.add( headChild ); + continue headNodeLoop; + } + } + missingNodeData.push( nodeData ); + } + + // @todo Identify the head children + // Now delete all nodes that + const unmatchedElements = []; + for ( const headChild of document.head.children ) { + if ( ! alreadyMatchedElements.has( headChild ) ) { + unmatchedElements.push( headChild ); + } + } + + // @todo Update style elements. + // @todo Update title element. + // @todo Update JSON+LD element. + // @todo Update rel=preconnect? + + console.info( 'missingNodeData', missingNodeData ); + console.info( 'unmatchingElements', unmatchedElements ); + + return; + + // + + /* First, delete all nodes that are not elements since they are irrelevant. */ + node = document.head.firstChild; + while ( nextNode ) { + node = nextNode; + nextNode = nextNode.nextSibling; + if ( node.nodeType === 1 ) { + document.head.removeChild( node ); + } else { + document.head.removeChild( node ); + } + } + + refNode = document.head.firstChild; + while ( data.head_nodes.length ) { + nodeData = data.head_nodes.shift(); + if ( '#comment' === nodeData[ 0 ] ) { + const comment = document.createElement( nodeData[ 1 ] ); + document.head.insertBefore( comment, refNode ); + refNode = comment; + } else { + elements = document.head.getElementsByTagName( nodeData[ 0 ] ); + for ( let i = 0; i < elements.length; i++ ) { + // if ( ) { + // + // } + } + } + } + + + // If it is the title, then it matches. + // If it is meta[charset] then it matches. + // If it is style[amp-custom] then it matches + // If it is style[amp-boilerplate] then it matches. + // No need to delete nodes; just replace/add. + + var createNode = function( nodeData ) { + + }; + var applyNodeChanges = function ( node, nodeData ) { + + }; + + // // Replace all head nodes. @todo This should be smarter about only modifying elements when they differ. + // for ( i = 0; i < document.head.childNodes.length; i++ ) { + // if ( document.head.childNodes[ i ].nodeType !== 1 || ! document.head.childNodes[ i ].hasAttribute( 'amp-custom' ) ) { + // continue; + // } + // const style = document.head.childNodes[ i ]; + // for ( j = 0; j < data.head_nodes.length; j++ ) { + // if ( 'style' === data.head_nodes[ j ][ 0 ] && 'amp-custom' in data.head_nodes[ j ][ 1 ] ) { + // style.firstChild.nodeValue = data.head_nodes[ j ][ 2 ]; + // break; + // } + // } + // break; + // } + + node = document.head.firstChild; + while ( node ) { + node = node.nextSibling; + document.head.removeChild( document.head.firstChild ); + } for ( i = 0; i < data.head_nodes.length; i++ ) { - if ( 'title' === data.head_nodes[ i ][ 0 ] ) { - document.title = data.head_nodes[ i ][ 2 ]; // This should be crafted for actual + if ( '#comment' === data.head_nodes[ i ][ 0 ] ) { + node = document.createComment( data.head_nodes[ i ][ 1 ] ); + } else { + node = document.createElement( data.head_nodes[ i ][ 0 ] ); + for ( const key in data.head_nodes[ i ][ 1 ] ) { + node.setAttribute( key, data.head_nodes[ i ][ 1 ][ key ] ); + } + // console.info(node.nodeName) + // if ( 'style' === node.nodeName.toLowerCase() ) { + // node.textContent = data.head_nodes[ i ][ 2 ]; + // } + } + document.head.appendChild( node ); + if ( 'string' === typeof data.head_nodes[ i ][ 2 ] ) { + // node.appendChild( data.head_nodes[ i ][ 2 ] ); + // node.appendChild( document.createTextNode( '' ) ); + // node.firstChild.nodeValue = data.head_nodes[ i ][ 2 ]; } } + // Populate body attributes. + for ( const key in data.body_attributes ) { + document.body.setAttribute( key, data.body_attributes[ key ] ); + } + /* Purge all traces of the stream combination logic to ensure the AMP validator doesn't complain at runtime. */ - removedElements = [ + const removedElements = [ 'wp-stream-fragment-boundary', 'wp-stream-combine-function', 'wp-stream-combine-call' ]; for ( i = 0; i < removedElements.length; i++ ) { - element = document.getElementById( removedElements[ i ] ); + const element = document.getElementById( removedElements[ i ] ); if ( element ) { element.parentNode.removeChild( element ); } From 85e2efd02c3f9fe8a3a237317e53db00e346a424 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 12 Oct 2018 17:59:22 -0700 Subject: [PATCH 06/25] Pull back diffing logic to just target subset of AMP support --- .../js/service-worker-stream-combiner.js | 170 ++++-------------- 1 file changed, 31 insertions(+), 139 deletions(-) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 48fd76e40..99cbc9c1d 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -10,149 +10,41 @@ * @param {Object} data.body_attributes - Attributes on body. */ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ - var node, nextNode, nodeData, refNode, elements; - - // Keep track of the elements we matched already so we don't keep updating the same element. - const alreadyMatchedElements = new WeakSet(); - - const isElementMatchingData = ( element, newElementData ) => { - if ( element.nodeName.toLowerCase() !== newElementData[ 0 ] ) { - return false; - } - const elementAttributes = Array.prototype.map.call( element.attributes, ( attribute ) => { - return attribute.nodeName + '=' + attribute.nodeValue; - } ).sort().join( ';' ); - - const dataAttributes = ! newElementData[ 1 ] ? '' : Object.entries( newElementData[ 1 ] ).map( ( [ key, value ] ) => { - return key + '=' + value; - } ).sort().join( ';' ); - - if ( elementAttributes !== dataAttributes ) { - return false; - } - - if ( 'undefined' === typeof newElementData[ 2 ] ) { - return ! element.firstChild; - } else { - return element.firstChild === newElementData[ 2 ]; - } - }; - - const missingNodeData = []; - headNodeLoop: - for ( const nodeData of data.head_nodes ) { - for ( const headChild of document.head.children ) { - if ( isElementMatchingData( headChild, nodeData ) ) { - alreadyMatchedElements.add( headChild ); - continue headNodeLoop; - } - } - missingNodeData.push( nodeData ); + let nodeData; + + // Update title. + nodeData = data.head_nodes.find( ( thisNodeData ) => { + return 'title' === thisNodeData[ 0 ]; + } ); + if ( nodeData ) { + document.title = nodeData[ 2 ]; } - // @todo Identify the head children - // Now delete all nodes that - const unmatchedElements = []; - for ( const headChild of document.head.children ) { - if ( ! alreadyMatchedElements.has( headChild ) ) { - unmatchedElements.push( headChild ); - } - } - - // @todo Update style elements. - // @todo Update title element. - // @todo Update JSON+LD element. - // @todo Update rel=preconnect? - - console.info( 'missingNodeData', missingNodeData ); - console.info( 'unmatchingElements', unmatchedElements ); - - return; - - // - - /* First, delete all nodes that are not elements since they are irrelevant. */ - node = document.head.firstChild; - while ( nextNode ) { - node = nextNode; - nextNode = nextNode.nextSibling; - if ( node.nodeType === 1 ) { - document.head.removeChild( node ); - } else { - document.head.removeChild( node ); - } + // Update style[amp-custom]. + const ampCustom = document.head.querySelector( 'style[amp-custom]' ); + nodeData = data.head_nodes.find( ( thisNodeData ) => { + return 'style' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'amp-custom' in thisNodeData[ 1 ] ); + } ); + if ( ampCustom && nodeData ) { + ampCustom.firstChild.nodeValue = nodeData[ 2 ]; } - refNode = document.head.firstChild; - while ( data.head_nodes.length ) { - nodeData = data.head_nodes.shift(); - if ( '#comment' === nodeData[ 0 ] ) { - const comment = document.createElement( nodeData[ 1 ] ); - document.head.insertBefore( comment, refNode ); - refNode = comment; - } else { - elements = document.head.getElementsByTagName( nodeData[ 0 ] ); - for ( let i = 0; i < elements.length; i++ ) { - // if ( ) { - // - // } - } - } + // Update rel=canonical link. + const relCanonical = document.head.querySelector( 'link[rel=canonical]' ); + nodeData = data.head_nodes.find( ( thisNodeData ) => { + return 'link' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'canonical' === thisNodeData[ 1 ]['rel'] ); + } ); + if ( relCanonical && nodeData ) { + relCanonical.setAttribute( 'href', nodeData[ 1 ][ 'href' ] ) } - - // If it is the title, then it matches. - // If it is meta[charset] then it matches. - // If it is style[amp-custom] then it matches - // If it is style[amp-boilerplate] then it matches. - // No need to delete nodes; just replace/add. - - var createNode = function( nodeData ) { - - }; - var applyNodeChanges = function ( node, nodeData ) { - - }; - - // // Replace all head nodes. @todo This should be smarter about only modifying elements when they differ. - // for ( i = 0; i < document.head.childNodes.length; i++ ) { - // if ( document.head.childNodes[ i ].nodeType !== 1 || ! document.head.childNodes[ i ].hasAttribute( 'amp-custom' ) ) { - // continue; - // } - // const style = document.head.childNodes[ i ]; - // for ( j = 0; j < data.head_nodes.length; j++ ) { - // if ( 'style' === data.head_nodes[ j ][ 0 ] && 'amp-custom' in data.head_nodes[ j ][ 1 ] ) { - // style.firstChild.nodeValue = data.head_nodes[ j ][ 2 ]; - // break; - // } - // } - // break; - // } - - node = document.head.firstChild; - while ( node ) { - node = node.nextSibling; - document.head.removeChild( document.head.firstChild ); - } - for ( i = 0; i < data.head_nodes.length; i++ ) { - if ( '#comment' === data.head_nodes[ i ][ 0 ] ) { - node = document.createComment( data.head_nodes[ i ][ 1 ] ); - } else { - node = document.createElement( data.head_nodes[ i ][ 0 ] ); - for ( const key in data.head_nodes[ i ][ 1 ] ) { - node.setAttribute( key, data.head_nodes[ i ][ 1 ][ key ] ); - } - // console.info(node.nodeName) - // if ( 'style' === node.nodeName.toLowerCase() ) { - // node.textContent = data.head_nodes[ i ][ 2 ]; - // } - } - document.head.appendChild( node ); - if ( 'string' === typeof data.head_nodes[ i ][ 2 ] ) { - // node.appendChild( data.head_nodes[ i ][ 2 ] ); - // node.appendChild( document.createTextNode( '' ) ); - // node.firstChild.nodeValue = data.head_nodes[ i ][ 2 ]; - } + // Update Schema.org data. + const schemaScript = document.head.querySelector( 'script[type="application/ld+json"]' ); + nodeData = data.head_nodes.find( ( thisNodeData ) => { + return 'script' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'application/ld+json' === thisNodeData[ 1 ]['type'] ); + } ); + if ( schemaScript && nodeData ) { + schemaScript.firstChild.nodeValue = nodeData[ 2 ]; } // Populate body attributes. @@ -166,8 +58,8 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ 'wp-stream-combine-function', 'wp-stream-combine-call' ]; - for ( i = 0; i < removedElements.length; i++ ) { - const element = document.getElementById( removedElements[ i ] ); + for ( const elementId of removedElements ) { + const element = document.getElementById( elementId ); if ( element ) { element.parentNode.removeChild( element ); } From e7e4522e50017b820c36e0cfe4750905f18ee826 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 13 Oct 2018 10:40:54 -0700 Subject: [PATCH 07/25] Prevent streaming from applying in admin --- ...ce-worker-navigation-routing-component.php | 26 +++++++++---------- .../js/service-worker-navigation-routing.js | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index db9a03960..d58ae8367 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -103,10 +103,10 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { $template = get_template(); $stylesheet = get_stylesheet(); - $theme_supports_streaming = current_theme_supports( self::STREAM_THEME_SUPPORT ); + $should_stream_response = ! is_admin() && current_theme_supports( self::STREAM_THEME_SUPPORT ); $stream_combiner_revision = ''; - if ( $theme_supports_streaming ) { - $stream_combiner_revision = md5( file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) ); + if ( $should_stream_response ) { + $stream_combiner_revision = md5( file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents } $revision = sprintf( '%s-v%s', $template, wp_get_theme( $template )->Version ); @@ -188,7 +188,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { if ( $offline_error_precache_entry ) { $scripts->precaching_routes()->register( $offline_error_precache_entry['url'], isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : null ); - if ( $theme_supports_streaming ) { + if ( $should_stream_response ) { $scripts->precaching_routes()->register( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ), ( isset( $offline_error_precache_entry['revision'] ) ? $offline_error_precache_entry['revision'] : '' ) . $stream_combiner_revision @@ -197,7 +197,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { } if ( $server_error_precache_entry ) { $scripts->precaching_routes()->register( $server_error_precache_entry['url'], isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : null ); - if ( $theme_supports_streaming ) { + if ( $should_stream_response ) { $scripts->precaching_routes()->register( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ), ( isset( $server_error_precache_entry['revision'] ) ? $server_error_precache_entry['revision'] : '' ) . $stream_combiner_revision @@ -207,7 +207,7 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { // Streaming. $streaming_header_precache_entry = null; - if ( $theme_supports_streaming ) { + if ( $should_stream_response ) { $header_template_file = locate_template( array( 'header.php' ) ); $streaming_header_precache_entry = array( 'url' => add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'header', home_url( '/' ) ), @@ -241,14 +241,14 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { } $this->replacements = array( - 'ERROR_OFFLINE_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $offline_error_precache_entry['url'] ) : null, - 'ERROR_OFFLINE_BODY_FRAGMENT_URL' => isset( $offline_error_precache_entry['url'] ) ? wp_service_worker_json_encode( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ) ) : null, - 'ERROR_500_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( $server_error_precache_entry['url'] ) : null, - 'ERROR_500_BODY_FRAGMENT_URL' => isset( $server_error_precache_entry['url'] ) ? wp_service_worker_json_encode( add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ) ) : null, - 'STREAM_HEADER_FRAGMENT_URL' => isset( $streaming_header_precache_entry['url'] ) ? wp_service_worker_json_encode( $streaming_header_precache_entry['url'] ) : null, + 'ERROR_OFFLINE_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? $offline_error_precache_entry['url'] : null ), + 'ERROR_OFFLINE_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ) : null ), + 'ERROR_500_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? $server_error_precache_entry['url'] : null ), + 'ERROR_500_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ) : null ), + 'STREAM_HEADER_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $streaming_header_precache_entry['url'] ) ? $streaming_header_precache_entry['url'] : null ), 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), - 'THEME_SUPPORTS_STREAMING' => $theme_supports_streaming, - 'STREAM_HEADER_FRAGMENT_QUERY_VAR' => wp_json_encode( self::STREAM_FRAGMENT_QUERY_VAR ), + 'SHOULD_STREAM_RESPONSE' => wp_service_worker_json_encode( $should_stream_response ), + 'STREAM_HEADER_FRAGMENT_QUERY_VAR' => wp_service_worker_json_encode( self::STREAM_FRAGMENT_QUERY_VAR ), ); } diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 59d4823ff..7ebf6e0d7 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -1,6 +1,6 @@ -/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, THEME_SUPPORTS_STREAMING, STREAM_HEADER_FRAGMENT_URL, ERROR_500_BODY_FRAGMENT_URL, ERROR_OFFLINE_BODY_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */ +/* global console, ERROR_OFFLINE_URL, ERROR_500_URL, SHOULD_STREAM_RESPONSE, STREAM_HEADER_FRAGMENT_URL, ERROR_500_BODY_FRAGMENT_URL, ERROR_OFFLINE_BODY_FRAGMENT_URL, STREAM_HEADER_FRAGMENT_QUERY_VAR, BLACKLIST_PATTERNS */ -const isStreamingResponses = THEME_SUPPORTS_STREAMING && wp.serviceWorker.streams.isSupported(); +const isStreamingResponses = SHOULD_STREAM_RESPONSE && wp.serviceWorker.streams.isSupported(); wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationRoute( async function ( { event } ) { From baf00f1fc6786c9b769a6ecbf2e66d2a436891b2 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sat, 13 Oct 2018 11:13:24 -0700 Subject: [PATCH 08/25] Update meta from body fragment and add scripts --- .../js/service-worker-stream-combiner.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 99cbc9c1d..f4103fffb 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -11,12 +11,46 @@ */ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ let nodeData; + const processedHeadNodeData = new WeakSet(); + + // Mark all identical nodes as having already been processed. + const isElementMatchingData = ( element, newElementData ) => { + if ( element.nodeName.toLowerCase() !== newElementData[0] ) { + return false; + } + const elementAttributes = Array.prototype.map.call( element.attributes, ( attribute ) => { + return attribute.nodeName + '=' + attribute.nodeValue; + } ).sort().join( ';' ); + + const dataAttributes = !newElementData[1] ? '' : Object.entries( newElementData[1] ).map( ( [key, value] ) => { + return key + '=' + value; + } ).sort().join( ';' ); + + if ( elementAttributes !== dataAttributes ) { + return false; + } + + if ( 'undefined' === typeof newElementData[2] ) { + return !element.firstChild; + } else { + return element.firstChild === newElementData[2]; + } + }; + data.head_nodes.filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ).forEach( ( headNodeData ) => { + for ( const headChildElement of document.head.getElementsByTagName( headNodeData[ 0 ] ) ) { + if ( isElementMatchingData( headChildElement, headNodeData ) ) { + processedHeadNodeData.add( headNodeData ); + break; + } + } + } ); // Update title. nodeData = data.head_nodes.find( ( thisNodeData ) => { return 'title' === thisNodeData[ 0 ]; } ); if ( nodeData ) { + processedHeadNodeData.add( nodeData ); document.title = nodeData[ 2 ]; } @@ -26,6 +60,7 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return 'style' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'amp-custom' in thisNodeData[ 1 ] ); } ); if ( ampCustom && nodeData ) { + processedHeadNodeData.add( nodeData ); ampCustom.firstChild.nodeValue = nodeData[ 2 ]; } @@ -35,6 +70,7 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return 'link' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'canonical' === thisNodeData[ 1 ]['rel'] ); } ); if ( relCanonical && nodeData ) { + processedHeadNodeData.add( nodeData ); relCanonical.setAttribute( 'href', nodeData[ 1 ][ 'href' ] ) } @@ -44,9 +80,55 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return 'script' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'application/ld+json' === thisNodeData[ 1 ]['type'] ); } ); if ( schemaScript && nodeData ) { + processedHeadNodeData.add( nodeData ); schemaScript.firstChild.nodeValue = nodeData[ 2 ]; } + // Update meta tags. + const metaHeadNodeDataLookup = new Map(); + data.head_nodes.forEach( ( nodeData ) => { + if ( 'meta' !== nodeData[ 0 ] || ! nodeData[ 1 ] || 'undefined' === typeof nodeData[ 1 ][ 'content' ] ) { + return; + } + if ( 'property' in nodeData[ 1 ] ) { + metaHeadNodeDataLookup.set( 'property:' + nodeData[ 1 ][ 'property' ], nodeData ); + } + if ( 'name' in nodeData[ 1 ] ) { + metaHeadNodeDataLookup.set( 'name:' + nodeData[ 1 ][ 'name' ], nodeData ); + } + } ); + document.head.querySelectorAll( 'meta[name][content]' ).forEach( ( meta ) => { + const nodeData = metaHeadNodeDataLookup.get( 'name:' + meta.getAttribute( 'name' ) ); + if ( nodeData ) { + meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ) + processedHeadNodeData.add( nodeData ); + } + } ); + document.head.querySelectorAll( 'meta[property][content]' ).forEach( ( meta ) => { + const nodeData = metaHeadNodeDataLookup.get( 'property:' + meta.getAttribute( 'name' ) ); + if ( nodeData ) { + meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ) + processedHeadNodeData.add( nodeData ); + } + } ); + + // Add remaining elements + data.head_nodes.forEach( ( headNodeData ) => { + if ( processedHeadNodeData.has( headNodeData ) ) { + return; + } + + // @todo More to be done. + if ( 'script' === headNodeData[ 0 ] && headNodeData[ 1 ] && 'src' in headNodeData[ 1 ] ) { + const script = document.createElement( 'script' ); + for ( const [ name, value ] of Object.entries( headNodeData[ 1 ] ) ) { + script.setAttribute( name, value ); + } + document.head.appendChild( script ); + } + } ); + + // Populate body attributes. for ( const key in data.body_attributes ) { document.body.setAttribute( key, data.body_attributes[ key ] ); From 52a71f60bce34bab83aeec56f2992b287e2bcac5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 12:32:07 -0700 Subject: [PATCH 09/25] WIP: Improve DOM diffing --- .../js/service-worker-stream-combiner.js | 130 +++++++++++++++--- 1 file changed, 114 insertions(+), 16 deletions(-) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index f4103fffb..e28924daf 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -12,30 +12,128 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ let nodeData; const processedHeadNodeData = new WeakSet(); + const processedHeadElements = new WeakSet(); + const variableLinkRels = new Set( [ 'canonical', 'shortlink' ] ); + + const getElementVariableAttributes = ( element ) => { + const nodeName = element.nodeName.toLowerCase(); + if ( 'meta' === nodeName ) { + return new Set( [ 'content' ] ); + } + if ( 'link' === nodeName ) { + return new Set( [ 'href', 'title' ] ); // @todo There are multiple rel=preconnects. + } + return new Set(); + }; + + // @todo Rename to getElementMatch. // Mark all identical nodes as having already been processed. const isElementMatchingData = ( element, newElementData ) => { if ( element.nodeName.toLowerCase() !== newElementData[0] ) { return false; } - const elementAttributes = Array.prototype.map.call( element.attributes, ( attribute ) => { - return attribute.nodeName + '=' + attribute.nodeValue; - } ).sort().join( ';' ); - const dataAttributes = !newElementData[1] ? '' : Object.entries( newElementData[1] ).map( ( [key, value] ) => { - return key + '=' + value; - } ).sort().join( ';' ); + const variableAttributes = getElementVariableAttributes( element ); - if ( elementAttributes !== dataAttributes ) { - return false; - } + const elementAttributes = Array.from( element.attributes ) + /*.filter( ( attribute ) => ! variableAttributes.has( attribute.nodeName ) )*/ + .map( ( attribute ) => { + return attribute.nodeName + '=' + attribute.nodeValue; + } ) + .sort().join( ';' ); - if ( 'undefined' === typeof newElementData[2] ) { - return !element.firstChild; - } else { - return element.firstChild === newElementData[2]; - } + const dataAttributes = Object.entries( newElementData[ 1 ] || {} ) + /*.filter( ( [ name ] ) => ! variableAttributes.has( name ) )*/ + .map( ( [ name, value ] ) => { + return name + '=' + value; + } ) + .sort().join( ';' ); + + return elementAttributes === dataAttributes; + // if ( elementAttributes !== dataAttributes ) { + // return false; + // } + // + // if ( 'undefined' === typeof newElementData[2] ) { + // return ! element.firstChild; + // } else { + // return element.firstChild === newElementData[2]; + // } }; + + // Remove links and meta which are probably all stale in the header. + const preservedLinkRels = new Set( [ + 'EditURI', + 'apple-touch-icon-precomposed', + 'dns-prefetch', + 'https://api.w.org/', + 'icon', + 'pingback', + 'preconnect', + 'preload', + 'profile', + 'stylesheet', + 'wlwmanifest' + ] ); + Array.from( document.head.querySelectorAll( 'link[rel]' ) ).forEach( ( link ) => { + if ( ! preservedLinkRels.has( link.rel ) ) { + link.remove(); + } + } ); + const preservedMeta = new Set( [ + 'viewport', + 'generator', + 'msapplication-TileImage', + ] ); + Array.from( document.head.querySelectorAll( 'meta[name],meta[property]' ) ).forEach( ( meta ) => { + if ( ! preservedMeta.has( meta.getAttribute( 'name' ) || meta.getAttribute( 'property' ) ) ) { + meta.remove(); + } + } ); + + data.head_nodes + .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ) + .forEach( ( headNodeData ) => { + const headChildElement = Array.from( document.head.getElementsByTagName( headNodeData[ 0 ] ) ).find( ( element ) => { + return ! processedHeadElements.has( element ) && isElementMatchingData( element, headNodeData ) + } ); + if ( ! headChildElement ) { + return; + } + const variableAttributes = getElementVariableAttributes( headChildElement ); + + // Update variable attributes. + variableAttributes.forEach( ( variableAttributeName ) => { + if ( headNodeData[ 1 ] && headChildElement.getAttribute( variableAttributeName ) !== headNodeData[ 1 ][ variableAttributeName ] ) { + headChildElement.setAttribute( variableAttributeName, headNodeData[ 1 ][ variableAttributeName ] ); + } + } ); + + // Set node content if different. + if ( 'undefined' !== typeof headNodeData[ 2 ] && headChildElement.firstChild && headChildElement.firstChild.nodeType === 3 && headNodeData[ 2 ] !== headChildElement.firstChild.nodeValue ) { + headChildElement.firstChild.nodeValue = headNodeData[ 2 ]; + console.info( 'update', headChildElement, headNodeData ); + } + + processedHeadElements.add( headChildElement ); + processedHeadNodeData.add( headNodeData ); + } ); + + // Now create elements for each of the remaining. + data.head_nodes + .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] && ! processedHeadNodeData.has( headNodeData ) ) + .forEach( ( headNodeData ) => { + const element = document.createElement( headNodeData[ 0 ] ); + for ( const [ name, value ] of Object.entries( headNodeData[ 1 ] || {} ) ) { + element.setAttribute( name, value ); + } + console.info( 'create', element ); + document.head.appendChild( element ); + } ); + + return; + data.head_nodes.filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ).forEach( ( headNodeData ) => { for ( const headChildElement of document.head.getElementsByTagName( headNodeData[ 0 ] ) ) { if ( isElementMatchingData( headChildElement, headNodeData ) ) { @@ -100,14 +198,14 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ document.head.querySelectorAll( 'meta[name][content]' ).forEach( ( meta ) => { const nodeData = metaHeadNodeDataLookup.get( 'name:' + meta.getAttribute( 'name' ) ); if ( nodeData ) { - meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ) + meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ); processedHeadNodeData.add( nodeData ); } } ); document.head.querySelectorAll( 'meta[property][content]' ).forEach( ( meta ) => { const nodeData = metaHeadNodeDataLookup.get( 'property:' + meta.getAttribute( 'name' ) ); if ( nodeData ) { - meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ) + meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ); processedHeadNodeData.add( nodeData ); } } ); From 992f4793e111ba458eed49090f22faddcdac77e8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 12:55:44 -0700 Subject: [PATCH 10/25] Reduce amount of code needed to apply DOM changes --- .../js/service-worker-stream-combiner.js | 132 +----------------- 1 file changed, 1 insertion(+), 131 deletions(-) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index e28924daf..5d48bef99 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -10,23 +10,9 @@ * @param {Object} data.body_attributes - Attributes on body. */ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ - let nodeData; const processedHeadNodeData = new WeakSet(); const processedHeadElements = new WeakSet(); - const variableLinkRels = new Set( [ 'canonical', 'shortlink' ] ); - - const getElementVariableAttributes = ( element ) => { - const nodeName = element.nodeName.toLowerCase(); - if ( 'meta' === nodeName ) { - return new Set( [ 'content' ] ); - } - if ( 'link' === nodeName ) { - return new Set( [ 'href', 'title' ] ); // @todo There are multiple rel=preconnects. - } - return new Set(); - }; - // @todo Rename to getElementMatch. // Mark all identical nodes as having already been processed. const isElementMatchingData = ( element, newElementData ) => { @@ -34,8 +20,6 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return false; } - const variableAttributes = getElementVariableAttributes( element ); - const elementAttributes = Array.from( element.attributes ) /*.filter( ( attribute ) => ! variableAttributes.has( attribute.nodeName ) )*/ .map( ( attribute ) => { @@ -51,15 +35,6 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ .sort().join( ';' ); return elementAttributes === dataAttributes; - // if ( elementAttributes !== dataAttributes ) { - // return false; - // } - // - // if ( 'undefined' === typeof newElementData[2] ) { - // return ! element.firstChild; - // } else { - // return element.firstChild === newElementData[2]; - // } }; // Remove links and meta which are probably all stale in the header. @@ -101,19 +76,10 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ if ( ! headChildElement ) { return; } - const variableAttributes = getElementVariableAttributes( headChildElement ); - // Update variable attributes. - variableAttributes.forEach( ( variableAttributeName ) => { - if ( headNodeData[ 1 ] && headChildElement.getAttribute( variableAttributeName ) !== headNodeData[ 1 ][ variableAttributeName ] ) { - headChildElement.setAttribute( variableAttributeName, headNodeData[ 1 ][ variableAttributeName ] ); - } - } ); - - // Set node content if different. + // Update node text if different. if ( 'undefined' !== typeof headNodeData[ 2 ] && headChildElement.firstChild && headChildElement.firstChild.nodeType === 3 && headNodeData[ 2 ] !== headChildElement.firstChild.nodeValue ) { headChildElement.firstChild.nodeValue = headNodeData[ 2 ]; - console.info( 'update', headChildElement, headNodeData ); } processedHeadElements.add( headChildElement ); @@ -128,105 +94,9 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ for ( const [ name, value ] of Object.entries( headNodeData[ 1 ] || {} ) ) { element.setAttribute( name, value ); } - console.info( 'create', element ); document.head.appendChild( element ); } ); - return; - - data.head_nodes.filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ).forEach( ( headNodeData ) => { - for ( const headChildElement of document.head.getElementsByTagName( headNodeData[ 0 ] ) ) { - if ( isElementMatchingData( headChildElement, headNodeData ) ) { - processedHeadNodeData.add( headNodeData ); - break; - } - } - } ); - - // Update title. - nodeData = data.head_nodes.find( ( thisNodeData ) => { - return 'title' === thisNodeData[ 0 ]; - } ); - if ( nodeData ) { - processedHeadNodeData.add( nodeData ); - document.title = nodeData[ 2 ]; - } - - // Update style[amp-custom]. - const ampCustom = document.head.querySelector( 'style[amp-custom]' ); - nodeData = data.head_nodes.find( ( thisNodeData ) => { - return 'style' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'amp-custom' in thisNodeData[ 1 ] ); - } ); - if ( ampCustom && nodeData ) { - processedHeadNodeData.add( nodeData ); - ampCustom.firstChild.nodeValue = nodeData[ 2 ]; - } - - // Update rel=canonical link. - const relCanonical = document.head.querySelector( 'link[rel=canonical]' ); - nodeData = data.head_nodes.find( ( thisNodeData ) => { - return 'link' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'canonical' === thisNodeData[ 1 ]['rel'] ); - } ); - if ( relCanonical && nodeData ) { - processedHeadNodeData.add( nodeData ); - relCanonical.setAttribute( 'href', nodeData[ 1 ][ 'href' ] ) - } - - // Update Schema.org data. - const schemaScript = document.head.querySelector( 'script[type="application/ld+json"]' ); - nodeData = data.head_nodes.find( ( thisNodeData ) => { - return 'script' === thisNodeData[ 0 ] && ( thisNodeData[ 1 ] && 'application/ld+json' === thisNodeData[ 1 ]['type'] ); - } ); - if ( schemaScript && nodeData ) { - processedHeadNodeData.add( nodeData ); - schemaScript.firstChild.nodeValue = nodeData[ 2 ]; - } - - // Update meta tags. - const metaHeadNodeDataLookup = new Map(); - data.head_nodes.forEach( ( nodeData ) => { - if ( 'meta' !== nodeData[ 0 ] || ! nodeData[ 1 ] || 'undefined' === typeof nodeData[ 1 ][ 'content' ] ) { - return; - } - if ( 'property' in nodeData[ 1 ] ) { - metaHeadNodeDataLookup.set( 'property:' + nodeData[ 1 ][ 'property' ], nodeData ); - } - if ( 'name' in nodeData[ 1 ] ) { - metaHeadNodeDataLookup.set( 'name:' + nodeData[ 1 ][ 'name' ], nodeData ); - } - } ); - document.head.querySelectorAll( 'meta[name][content]' ).forEach( ( meta ) => { - const nodeData = metaHeadNodeDataLookup.get( 'name:' + meta.getAttribute( 'name' ) ); - if ( nodeData ) { - meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ); - processedHeadNodeData.add( nodeData ); - } - } ); - document.head.querySelectorAll( 'meta[property][content]' ).forEach( ( meta ) => { - const nodeData = metaHeadNodeDataLookup.get( 'property:' + meta.getAttribute( 'name' ) ); - if ( nodeData ) { - meta.setAttribute( 'content', nodeData[ 1 ][ 'content' ] ); - processedHeadNodeData.add( nodeData ); - } - } ); - - // Add remaining elements - data.head_nodes.forEach( ( headNodeData ) => { - if ( processedHeadNodeData.has( headNodeData ) ) { - return; - } - - // @todo More to be done. - if ( 'script' === headNodeData[ 0 ] && headNodeData[ 1 ] && 'src' in headNodeData[ 1 ] ) { - const script = document.createElement( 'script' ); - for ( const [ name, value ] of Object.entries( headNodeData[ 1 ] ) ) { - script.setAttribute( name, value ); - } - document.head.appendChild( script ); - } - } ); - - // Populate body attributes. for ( const key in data.body_attributes ) { document.body.setAttribute( key, data.body_attributes[ key ] ); From 720d18766fadb493ba2c1df3dacd46aaaed97f73 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 13:00:37 -0700 Subject: [PATCH 11/25] Prevent unnecessarily removing elements --- .../js/service-worker-stream-combiner.js | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 5d48bef99..3c88365ce 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -37,36 +37,6 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return elementAttributes === dataAttributes; }; - // Remove links and meta which are probably all stale in the header. - const preservedLinkRels = new Set( [ - 'EditURI', - 'apple-touch-icon-precomposed', - 'dns-prefetch', - 'https://api.w.org/', - 'icon', - 'pingback', - 'preconnect', - 'preload', - 'profile', - 'stylesheet', - 'wlwmanifest' - ] ); - Array.from( document.head.querySelectorAll( 'link[rel]' ) ).forEach( ( link ) => { - if ( ! preservedLinkRels.has( link.rel ) ) { - link.remove(); - } - } ); - const preservedMeta = new Set( [ - 'viewport', - 'generator', - 'msapplication-TileImage', - ] ); - Array.from( document.head.querySelectorAll( 'meta[name],meta[property]' ) ).forEach( ( meta ) => { - if ( ! preservedMeta.has( meta.getAttribute( 'name' ) || meta.getAttribute( 'property' ) ) ) { - meta.remove(); - } - } ); - data.head_nodes .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ) .forEach( ( headNodeData ) => { @@ -95,8 +65,39 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ element.setAttribute( name, value ); } document.head.appendChild( element ); + processedHeadElements.add( element ); } ); + // Remove links and meta which are probably all stale in the header. + const preservedLinkRels = new Set( [ + 'EditURI', + 'apple-touch-icon-precomposed', + 'dns-prefetch', + 'https://api.w.org/', + 'icon', + 'pingback', + 'preconnect', + 'preload', + 'profile', + 'stylesheet', + 'wlwmanifest' + ] ); + Array.from( document.head.querySelectorAll( 'link[rel]' ) ).forEach( ( link ) => { + if ( ! processedHeadElements.has( link ) && ! preservedLinkRels.has( link.rel ) ) { + link.remove(); + } + } ); + const preservedMeta = new Set( [ + 'viewport', + 'generator', + 'msapplication-TileImage', + ] ); + Array.from( document.head.querySelectorAll( 'meta[name],meta[property]' ) ).forEach( ( meta ) => { + if ( ! processedHeadElements.has( meta ) && ! preservedMeta.has( meta.getAttribute( 'name' ) || meta.getAttribute( 'property' ) ) ) { + meta.remove(); + } + } ); + // Populate body attributes. for ( const key in data.body_attributes ) { document.body.setAttribute( key, data.body_attributes[ key ] ); From ba3b3a4297865555f776909c644e614428536bbc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 16:30:48 -0700 Subject: [PATCH 12/25] Allow loading element to be customized --- ...ce-worker-navigation-routing-component.php | 22 ++++++++++++++++--- .../js/service-worker-stream-combiner.js | 1 + 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index d58ae8367..7751ee648 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -45,6 +45,15 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ protected $replacements = array(); + /** + * Determine whether the streaming header is being served. + * + * @return bool Whether streaming header is being served. + */ + public static function is_streaming_header() { + return current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); + } + /** * Add loading indicator for responses streamed from the service worker. * @@ -55,8 +64,10 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke * @since 2.0 * @todo Consider using progress element instead? * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. + * + * @param string $content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. */ - public static function print_stream_boundary() { + public static function print_stream_boundary( $content = '' ) { if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { _doing_it_wrong( __METHOD__, esc_html__( 'Failed to add "service_worker_streaming" theme support.', 'pwa' ), '0.2' ); return; @@ -66,11 +77,16 @@ public static function print_stream_boundary() { return; } + if ( ! $content ) { + $content = esc_html__( 'Loading…', 'pwa' ); + } + + // @todo There is no reason to print this in the body fragment printf( '
%s
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ), - esc_html__( 'Loading...', 'pwa' ) - ); + $content + ); // WPCS: XSS OK. // Short-circuit the response when requesting the header since there is nothing left to stream. if ( 'header' === $stream_fragment ) { diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 3c88365ce..89dfd26c0 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -13,6 +13,7 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ const processedHeadNodeData = new WeakSet(); const processedHeadElements = new WeakSet(); + // @todo Handle adding comments. // @todo Rename to getElementMatch. // Mark all identical nodes as having already been processed. const isElementMatchingData = ( element, newElementData ) => { From 1811ec938d01ef844012b28f07147203db6c7e9c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 16:53:11 -0700 Subject: [PATCH 13/25] Populate comment nodes in header --- ...ce-worker-navigation-routing-component.php | 20 ++++++------- .../js/service-worker-stream-combiner.js | 28 +++++++++++++++---- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 7751ee648..077c3c160 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -62,12 +62,11 @@ public static function is_streaming_header() { * This element is also used to demarcate the header (head) from the body (tail). * * @since 2.0 - * @todo Consider using progress element instead? * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. * - * @param string $content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. + * @param string $loading_content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. */ - public static function print_stream_boundary( $content = '' ) { + public static function print_stream_boundary( $loading_content = '' ) { if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { _doing_it_wrong( __METHOD__, esc_html__( 'Failed to add "service_worker_streaming" theme support.', 'pwa' ), '0.2' ); return; @@ -77,16 +76,15 @@ public static function print_stream_boundary( $content = '' ) { return; } - if ( ! $content ) { - $content = esc_html__( 'Loading…', 'pwa' ); + if ( ! $loading_content ) { + $loading_content = esc_html__( 'Loading…', 'pwa' ); } - // @todo There is no reason to print this in the body fragment - printf( - '
%s
', - esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ), - $content - ); // WPCS: XSS OK. + printf( '
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) ); + if ( 'header' === $stream_fragment ) { + echo $loading_content; // WPCS: XSS OK. + } + echo '
'; // Short-circuit the response when requesting the header since there is nothing left to stream. if ( 'header' === $stream_fragment ) { diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 89dfd26c0..177519e73 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -13,23 +13,25 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ const processedHeadNodeData = new WeakSet(); const processedHeadElements = new WeakSet(); - // @todo Handle adding comments. - // @todo Rename to getElementMatch. - // Mark all identical nodes as having already been processed. + /** + * Determine if a given element matches the nodeData coming from the body fragment. + * + * Returns true if the element name is the same and its attributes are equal. + * + * @returns {boolean} Matching. + */ const isElementMatchingData = ( element, newElementData ) => { if ( element.nodeName.toLowerCase() !== newElementData[0] ) { return false; } const elementAttributes = Array.from( element.attributes ) - /*.filter( ( attribute ) => ! variableAttributes.has( attribute.nodeName ) )*/ .map( ( attribute ) => { return attribute.nodeName + '=' + attribute.nodeValue; } ) .sort().join( ';' ); const dataAttributes = Object.entries( newElementData[ 1 ] || {} ) - /*.filter( ( [ name ] ) => ! variableAttributes.has( name ) )*/ .map( ( [ name, value ] ) => { return name + '=' + value; } ) @@ -38,6 +40,7 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ return elementAttributes === dataAttributes; }; + // Find identical nodes and update text content of any elements with matching attributes. data.head_nodes .filter( ( headNodeData ) => '#comment' !== headNodeData[ 0 ] ) .forEach( ( headNodeData ) => { @@ -99,6 +102,21 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ } } ); + // Replace comments. + const pendingCommentNodes = data.head_nodes.filter( ( headNodeData ) => '#comment' === headNodeData[ 0 ] ); + for ( const node of document.head.childNodes ) { + if ( 0 === pendingCommentNodes.length ) { + break; + } + if ( 8 === node.nodeType ) { + node.nodeValue = pendingCommentNodes.shift()[ 1 ]; + } + } + // Add remaining comments that didn't match up. + pendingCommentNodes.forEach( ( headNodeData ) => { + document.head.appendChild( document.createTextNode( headNodeData[ 1 ] ) ); + } ); + // Populate body attributes. for ( const key in data.body_attributes ) { document.body.setAttribute( key, data.body_attributes[ key ] ); From 2f760b666f5e4395c40fd555275c9eb0890c9d47 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 18:51:22 -0700 Subject: [PATCH 14/25] Prevent streaming on the login screen and other PHP endpoints --- wp-includes/js/service-worker-navigation-routing.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 7ebf6e0d7..78d8d5b4d 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -54,7 +54,12 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR } } - if ( isStreamingResponses ) { + const canStreamResponse = () => { + const url = new URL( event.request.url ); + return ! /\.php$/.test( url.pathname ); + }; + + if ( isStreamingResponses && canStreamResponse() ) { const streamHeaderFragmentURL = STREAM_HEADER_FRAGMENT_URL; const precacheStrategy = wp.serviceWorker.strategies.cacheFirst({ cacheName: wp.serviceWorker.core.cacheNames.precache, From 293bfdb3cf8868c4d7ea3d7026a80e39dfb5513c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 14 Oct 2018 20:01:19 -0700 Subject: [PATCH 15/25] Prevent streaming responses to direct fragment navigation --- wp-includes/js/service-worker-navigation-routing.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 78d8d5b4d..756c78e24 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -56,7 +56,10 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR const canStreamResponse = () => { const url = new URL( event.request.url ); - return ! /\.php$/.test( url.pathname ); + return ! ( + /\.php$/.test( url.pathname ) || + url.searchParams.has( STREAM_HEADER_FRAGMENT_QUERY_VAR ) + ); }; if ( isStreamingResponses && canStreamResponse() ) { From 831bcb6e75bc1aa191cf07433eafdc55b9075a1d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Mon, 15 Oct 2018 16:54:41 -0700 Subject: [PATCH 16/25] Fix handling of streaming body for URL that redirects --- wp-includes/default-filters.php | 1 + .../js/service-worker-navigation-routing.js | 11 ++++++++++- wp-includes/service-workers.php | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index 44c8413fb..5ec9422a4 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -16,6 +16,7 @@ add_action( 'wp_ajax_wp_service_worker', 'wp_ajax_wp_service_worker' ); add_action( 'wp_ajax_nopriv_wp_service_worker', 'wp_ajax_wp_service_worker' ); add_action( 'parse_query', 'wp_hide_admin_bar_offline' ); +add_filter( 'old_slug_redirect_url', 'wp_service_worker_fragment_redirect_old_slug_to_new_url' ); add_action( 'wp_head', 'wp_add_error_template_no_robots' ); add_filter( 'pre_get_document_title', 'WP_Service_Worker_Navigation_Routing_Component::filter_title_for_streaming_header' ); diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 756c78e24..0bdf5ceb8 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -8,7 +8,16 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR const handleResponse = ( response ) => { if ( response.status < 500 ) { - return response; + if ( response.redirected ) { + const redirectedUrl = new URL( response.url ); + redirectedUrl.searchParams.delete( STREAM_HEADER_FRAGMENT_QUERY_VAR ); + const script = ``; + return response.text().then( ( body ) => { + return new Response( script + body ); + } ); + } else { + return response; + } } const channel = new BroadcastChannel( 'wordpress-server-errors' ); diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php index b6bbce351..118ba6df8 100644 --- a/wp-includes/service-workers.php +++ b/wp-includes/service-workers.php @@ -419,3 +419,19 @@ function wp_prepare_stream_fragment_response( $dom, $fragment_name ) { return $response; } + +/** + * Preserve stream fragment query param on canonical redirects. + * + * @since 0.2 + * + * @param string $link New URL of the post. + * @return string URL to be redirected. + */ +function wp_service_worker_fragment_redirect_old_slug_to_new_url( $link ) { + $fragment = get_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); + if ( $fragment ) { + $link = add_query_arg( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR, sanitize_key( $fragment ), $link ); + } + return $link; +} From 16852cf88764d744ce3ac8715d4b7f05ff748c2e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 16 Oct 2018 15:53:37 -0700 Subject: [PATCH 17/25] WIP: Streaming non-AMP --- ...ce-worker-navigation-routing-component.php | 32 +++++++++++ wp-includes/default-filters.php | 2 + wp-includes/service-workers.php | 56 +++++++++++++++---- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 077c3c160..1a1993749 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -88,8 +88,40 @@ public static function print_stream_boundary( $loading_content = '' ) { // Short-circuit the response when requesting the header since there is nothing left to stream. if ( 'header' === $stream_fragment ) { + printf( + '', + file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore + ); exit; } + + // Handle serving the body. + $is_body_fragment = ( + 'body' === $stream_fragment + && + false !== has_action( 'template_redirect', 'wp_start_output_buffering_stream_fragment' ) + && + ob_get_level() > 0 + ); + if ( ! $is_body_fragment ) { + return; + } + $header_html = ob_get_clean(); + + $libxml_use_errors = libxml_use_internal_errors( true ); + $dom = new DOMDocument( $header_html ); + $result = $dom->loadHTML( $header_html ); + libxml_clear_errors(); + libxml_use_internal_errors( $libxml_use_errors ); + if ( ! $result ) { + wp_die( esc_html__( 'Failed to turn header into document.', 'pwa' ) ); + } + $response = wp_prepare_stream_fragment_response( $dom, 'body' ); + if ( is_wp_error( $response ) ) { + wp_die( esc_html( $response->get_error_message() ) ); + } + + echo $response; // WPCS: XSS OK. } /** diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index 5ec9422a4..aec158cbf 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -24,3 +24,5 @@ add_action( 'wp_default_service_workers', 'wp_default_service_workers' ); add_action( 'admin_init', 'wp_disable_script_concatenation' ); + +add_action( 'template_redirect', 'wp_start_output_buffering_stream_fragment', PHP_INT_MAX ); diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php index 118ba6df8..fcbccd405 100644 --- a/wp-includes/service-workers.php +++ b/wp-includes/service-workers.php @@ -319,11 +319,29 @@ function wp_disable_script_concatenation() { } } +/** + * Start output buffering for obtaining a stream fragment. + * + * This runs at template_redirect. If the theme dues not support streaming or the body fragment is not requested, + * then this function does nothing. + * + * @since 0.2 + */ +function wp_start_output_buffering_stream_fragment() { + if ( ! current_theme_supports( WP_Service_Worker_Navigation_Routing_Component::STREAM_THEME_SUPPORT ) ) { + return; + } + $stream_fragment = get_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); + if ( 'body' === $stream_fragment ) { + ob_start(); + } +} + + /** * Prepare stream fragment response. * * @since 0.2 - * @todo Hook this up to work in non-AMP responses. * * @param DOMDocument $dom Document. * @param string $fragment_name Fragment name. @@ -402,20 +420,38 @@ function wp_prepare_stream_fragment_response( $dom, $fragment_name ) { } // @todo Also obtain classes used in nav menus. - $boundary_element->parentNode->insertBefore( $dom->createComment( $boundary_comment_text ), $boundary_element ); - $boundary_element->parentNode->removeChild( $boundary_element ); // Only relevant to serve in the header fragment. - $serialized = AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); - $token_pos = strpos( $serialized, $search ); - if ( false === $token_pos ) { - return new WP_Error( 'fragment_boundary_not_found' ); - } - $response = sprintf( '', wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. ); - $response .= substr( $serialized, $token_pos + strlen( $search ) ); + // Include rest of body after the entire response was buffered. + if ( did_action( 'wp_footer' ) ) { + $boundary_element->parentNode->insertBefore( $dom->createComment( $boundary_comment_text ), $boundary_element ); + $boundary_element->parentNode->removeChild( $boundary_element ); // Only relevant to serve in the header fragment. + + /** + * Allow plugins to use their own means of serializing the DOM to an HTML string. + * + * This is needed because PHP versions various issues with serializing HTML. + * + * @since 0.2 + * @see AMP_DOM_Utils::get_content_from_dom_node() The AMP plugin has a method that accounts for various cases. + * + * @param null $pre The serialized HTML. Plugins should override this to short-circuit DOMDocument::saveHTML() from being called. + * @param DOMDocument $dom The document to be serialized. + */ + $serialized = apply_filters( 'pre_wp_service_worker_serialize_stream_fragment', null, $dom ); + if ( null === $serialized ) { + $serialized = $dom->saveHTML(); + } + + $token_pos = strpos( $serialized, $search ); + if ( false === $token_pos ) { + return new WP_Error( 'fragment_boundary_not_found' ); + } + $response .= substr( $serialized, $token_pos + strlen( $search ) ); + } return $response; } From 0e6985a85e74a52f02c1fb88999449717f062ffa Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 17 Oct 2018 17:25:10 -0700 Subject: [PATCH 18/25] WIP: Streaming non-AMP (2) [ci skip] --- readme.md | 18 +- ...ce-worker-navigation-routing-component.php | 226 +++++++++++++++--- wp-includes/default-filters.php | 2 +- wp-includes/service-workers.php | 137 ----------- 4 files changed, 207 insertions(+), 176 deletions(-) diff --git a/readme.md b/readme.md index ebca9621b..2b844dfdf 100644 --- a/readme.md +++ b/readme.md @@ -4,15 +4,15 @@ ![Banner](wp-assets/banner-1544x500.png) WordPress feature plugin to bring Progressive Web App (PWA) capabilities to Core -**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [google](https://profiles.wordpress.org/google), [automattic](https://profiles.wordpress.org/automattic) -**Tags:** [pwa](https://wordpress.org/plugins/tags/pwa), [progressive web apps](https://wordpress.org/plugins/tags/progressive-web-apps), [service workers](https://wordpress.org/plugins/tags/service-workers), [web app manifest](https://wordpress.org/plugins/tags/web-app-manifest), [https](https://wordpress.org/plugins/tags/https) -**Requires at least:** 4.9 -**Tested up to:** 4.9 -**Stable tag:** 0.1.0 -**License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html) -**Requires PHP:** 5.2 - -[![Build Status](https://travis-ci.org/xwp/pwa-wp.svg?branch=master)](https://travis-ci.org/xwp/pwa-wp) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com) +**Contributors:** [xwp](https://profiles.wordpress.org/xwp), [google](https://profiles.wordpress.org/google), [automattic](https://profiles.wordpress.org/automattic) +**Tags:** [pwa](https://wordpress.org/plugins/tags/pwa), [progressive web apps](https://wordpress.org/plugins/tags/progressive-web-apps), [service workers](https://wordpress.org/plugins/tags/service-workers), [web app manifest](https://wordpress.org/plugins/tags/web-app-manifest), [https](https://wordpress.org/plugins/tags/https) +**Requires at least:** 4.9 +**Tested up to:** 4.9 +**Stable tag:** 0.1.0 +**License:** [GPLv2 or later](http://www.gnu.org/licenses/gpl-2.0.html) +**Requires PHP:** 5.2 + +[![Build Status](https://travis-ci.org/xwp/pwa-wp.svg?branch=master)](https://travis-ci.org/xwp/pwa-wp) [![Built with Grunt](https://cdn.gruntjs.com/builtwith.svg)](http://gruntjs.com) ## Description ## diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 1a1993749..3a1f86db9 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -29,6 +29,22 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ const STREAM_THEME_SUPPORT = 'service_worker_streaming'; + /** + * ID for script element that contains the stream combine function definition. + * + * @since 0.2 + * @var string + */ + const STREAM_COMBINE_DEFINE_SCRIPT_ID = 'wp-stream-combine-function'; + + /** + * ID for script element that contains the stream combine function invocation. + * + * @since 0.2 + * @var string + */ + const STREAM_COMBINE_INVOKE_SCRIPT_ID = 'wp-stream-combine-function'; + /** * ID for the stream boundary element. * @@ -37,6 +53,20 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ const STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID = 'wp-stream-fragment-boundary'; + /** + * Start stream boundary. + * + * @var string + */ + const START_STREAM_BOUNDARY_COMMENT = 'WP_BEGIN_STREAM_BOUNDARY'; + + /** + * End stream boundary comment. + * + * @var string + */ + const END_STREAM_BOUNDARY_COMMENT = 'WP_END_STREAM_BOUNDARY'; + /** * Internal storage for replacements to make in the error response handling script. * @@ -62,11 +92,11 @@ public static function is_streaming_header() { * This element is also used to demarcate the header (head) from the body (tail). * * @since 2.0 - * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. + * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. Do we need this? * * @param string $loading_content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. */ - public static function print_stream_boundary( $loading_content = '' ) { + public static function do_stream_boundary( $loading_content = '' ) { if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { _doing_it_wrong( __METHOD__, esc_html__( 'Failed to add "service_worker_streaming" theme support.', 'pwa' ), '0.2' ); return; @@ -76,52 +106,190 @@ public static function print_stream_boundary( $loading_content = '' ) { return; } - if ( ! $loading_content ) { - $loading_content = esc_html__( 'Loading…', 'pwa' ); - } + $is_header = 'header' === $stream_fragment; - printf( '
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) ); +// printf( '', self::START_STREAM_BOUNDARY_COMMENT ); // WPCS: XSS OK. if ( 'header' === $stream_fragment ) { + printf( '
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) ); + if ( ! $loading_content ) { + $loading_content = esc_html__( 'Loading…', 'pwa' ); + } echo $loading_content; // WPCS: XSS OK. + echo '
'; } - echo '
'; - // Short-circuit the response when requesting the header since there is nothing left to stream. - if ( 'header' === $stream_fragment ) { + if ( $is_header ) { printf( - '', + '', + esc_attr( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ), file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore ); + } + + // @todo We don't need this really because we can just use STREAM_COMBINE_DEFINE_SCRIPT_ID as the marker. +// printf( '', self::END_STREAM_BOUNDARY_COMMENT ); // WPCS: XSS OK. + + // Short-circuit the response when requesting the header since there is nothing left to stream. + if ( $is_header ) { exit; } - // Handle serving the body. - $is_body_fragment = ( - 'body' === $stream_fragment - && - false !== has_action( 'template_redirect', 'wp_start_output_buffering_stream_fragment' ) + // Handle serving the body. Normally it is output-buffered here. + $is_header_buffered = ( + false !== has_action( 'template_redirect', 'WP_Service_Worker_Navigation_Routing_Component::start_output_buffering_stream_fragment' ) && ob_get_level() > 0 ); - if ( ! $is_body_fragment ) { + if ( $is_header_buffered ) { + $header_html = ob_get_clean(); + $libxml_use_errors = libxml_use_internal_errors( true ); + $header_html = preg_replace( '##s', '', $header_html ); // Some libxml versions croak at noscript in head. + $dom = new DOMDocument( $header_html ); + $result = $dom->loadHTML( $header_html ); + libxml_clear_errors(); + libxml_use_internal_errors( $libxml_use_errors ); + if ( ! $result ) { + wp_die( esc_html__( 'Failed to turn header into document.', 'pwa' ) ); + } + $response = self::prepare_stream_body_fragment( $dom ); + if ( is_wp_error( $response ) ) { + wp_die( esc_html( $response->get_error_message() ) ); + } + + echo $response; // WPCS: XSS OK. + } + } + /** + * Start output buffering for obtaining a stream fragment. + * + * This runs at template_redirect. If the theme dues not support streaming or the body fragment is not requested, + * then this function does nothing. + * + * @since 0.2 + */ + public static function start_output_buffering_stream_fragment() { + if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { return; } - $header_html = ob_get_clean(); - - $libxml_use_errors = libxml_use_internal_errors( true ); - $dom = new DOMDocument( $header_html ); - $result = $dom->loadHTML( $header_html ); - libxml_clear_errors(); - libxml_use_internal_errors( $libxml_use_errors ); - if ( ! $result ) { - wp_die( esc_html__( 'Failed to turn header into document.', 'pwa' ) ); + $stream_fragment = get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); + if ( 'body' === $stream_fragment ) { + ob_start(); + } + } + + /** + * Prepare stream header fragment. + * + * @since 0.2 + * + * @param DOMDocument $dom Document. + * @return string|WP_Error Header response or error. + */ +// public static function prepare_stream_header_fragment( $dom ) { +// $serialized = "\n"; +// $serialized .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); +// $token_pos = strpos( $serialized, sprintf( '', self::END_STREAM_BOUNDARY_COMMENT ) ); +// if ( false === $token_pos ) { +// return new WP_Error( 'fragment_boundary_not_found' ); +// } +// $response = substr( $serialized, 0, $token_pos ); +// $response .= sprintf( +// '', +// esc_attr( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ), +// file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore +// ); +// return $response; +// } + + /** + * Prepare stream body fragment. + * + * @since 0.2 + * + * @param DOMDocument $dom Document. + * @return string|WP_Error Body response or error. + */ + public static function prepare_stream_body_fragment( $dom ) { + + // Obtain body fragment. + $data = array( + // @todo Add root_attributes? + 'head_nodes' => array(), + 'body_attributes' => array(), + ); + $head = $dom->getElementsByTagName( 'head' )->item( 0 ); + if ( ! $head ) { + return new WP_Error( 'no_head' ); } - $response = wp_prepare_stream_fragment_response( $dom, 'body' ); - if ( is_wp_error( $response ) ) { - wp_die( esc_html( $response->get_error_message() ) ); + foreach ( $head->childNodes as $node ) { + if ( $node instanceof DOMElement ) { + if ( 'noscript' === $node->nodeName ) { + continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated. + } + $element = array( + $node->nodeName, + null, + ); + if ( $node->hasAttributes() ) { + $element[1] = array(); + foreach ( $node->attributes as $attribute ) { + $element[1][ $attribute->nodeName ] = $attribute->nodeValue; + } + } + if ( $node->firstChild instanceof DOMText ) { + $element[] = $node->firstChild->nodeValue; + } + $data['head_nodes'][] = $element; + } elseif ( $node instanceof DOMComment ) { + $data['head_nodes'][] = array( + '#comment', + $node->nodeValue, + ); + } + } + + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( ! $body ) { + return new WP_Error( 'no_body' ); + } + foreach ( $body->attributes as $attribute ) { + $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; + } + + // @todo Also obtain classes used in nav menus. + $response = sprintf( + '', + esc_attr( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ), + wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. + ); + + // Include rest of body after the entire response was buffered. + if ( did_action( 'wp_footer' ) ) { + /** + * Allow plugins to use their own means of serializing the DOM to an HTML string. + * + * This is needed because PHP versions various issues with serializing HTML. + * + * @since 0.2 + * @see AMP_DOM_Utils::get_content_from_dom_node() The AMP plugin has a method that accounts for various cases. + * + * @param null $pre The serialized HTML. Plugins should override this to short-circuit DOMDocument::saveHTML() from being called. + * @param DOMDocument $dom The document to be serialized. + */ + $serialized = apply_filters( 'pre_wp_service_worker_serialize_stream_fragment', null, $dom ); + if ( null === $serialized ) { + $serialized = $dom->saveHTML(); + } + + $search = sprintf( '', self::END_STREAM_BOUNDARY_COMMENT ); + $token_pos = strpos( $serialized, $search ); + if ( false === $token_pos ) { + return new WP_Error( 'fragment_boundary_not_found' ); + } + $response .= substr( $serialized, $token_pos + strlen( $search ) ); } - echo $response; // WPCS: XSS OK. + return $response; } /** diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php index aec158cbf..422bd4c4f 100644 --- a/wp-includes/default-filters.php +++ b/wp-includes/default-filters.php @@ -25,4 +25,4 @@ add_action( 'admin_init', 'wp_disable_script_concatenation' ); -add_action( 'template_redirect', 'wp_start_output_buffering_stream_fragment', PHP_INT_MAX ); +add_action( 'template_redirect', 'WP_Service_Worker_Navigation_Routing_Component::start_output_buffering_stream_fragment', PHP_INT_MAX ); diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php index fcbccd405..e9fd68292 100644 --- a/wp-includes/service-workers.php +++ b/wp-includes/service-workers.php @@ -319,143 +319,6 @@ function wp_disable_script_concatenation() { } } -/** - * Start output buffering for obtaining a stream fragment. - * - * This runs at template_redirect. If the theme dues not support streaming or the body fragment is not requested, - * then this function does nothing. - * - * @since 0.2 - */ -function wp_start_output_buffering_stream_fragment() { - if ( ! current_theme_supports( WP_Service_Worker_Navigation_Routing_Component::STREAM_THEME_SUPPORT ) ) { - return; - } - $stream_fragment = get_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); - if ( 'body' === $stream_fragment ) { - ob_start(); - } -} - - -/** - * Prepare stream fragment response. - * - * @since 0.2 - * - * @param DOMDocument $dom Document. - * @param string $fragment_name Fragment name. - * @return string|WP_Error Response fragment string, or WP_Error. - */ -function wp_prepare_stream_fragment_response( $dom, $fragment_name ) { - $boundary_element = $dom->getElementById( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ); - if ( ! $boundary_element ) { - return new WP_Error( 'no_fragment_boundary' ); - } - - $boundary_comment_text = 'WP_STREAM_FRAGMENT_BOUNDARY'; - $search = ""; - - // Obtain header fragment. - if ( 'header' === $fragment_name ) { - $serialized = "\n"; - $comment = $dom->createComment( $boundary_comment_text ); - $boundary_element->parentNode->insertBefore( $comment, $boundary_element->nextSibling ); - $serialized .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); - $token_pos = strpos( $serialized, $search ); - if ( false === $token_pos ) { - return new WP_Error( 'fragment_boundary_not_found' ); - } - $response = substr( $serialized, 0, $token_pos ); - $response .= sprintf( - '', - file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore - ); - return $response; - } - - // Obtain body fragment. - $data = array( - // @todo Add root_attributes? - 'head_nodes' => array(), - 'body_attributes' => array(), - ); - $head = $dom->getElementsByTagName( 'head' )->item( 0 ); - if ( ! $head ) { - return new WP_Error( 'no_head' ); - } - foreach ( $head->childNodes as $node ) { - if ( $node instanceof DOMElement ) { - if ( 'noscript' === $node->nodeName ) { - continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated. - } - $element = array( - $node->nodeName, - null, - ); - if ( $node->hasAttributes() ) { - $element[1] = array(); - foreach ( $node->attributes as $attribute ) { - $element[1][ $attribute->nodeName ] = $attribute->nodeValue; - } - } - if ( $node->firstChild instanceof DOMText ) { - $element[] = $node->firstChild->nodeValue; - } - $data['head_nodes'][] = $element; - } elseif ( $node instanceof DOMComment ) { - $data['head_nodes'][] = array( - '#comment', - $node->nodeValue, - ); - } - } - - $body = $dom->getElementsByTagName( 'body' )->item( 0 ); - if ( ! $body ) { - return new WP_Error( 'no_body' ); - } - foreach ( $body->attributes as $attribute ) { - $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; - } - - // @todo Also obtain classes used in nav menus. - $response = sprintf( - '', - wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. - ); - - // Include rest of body after the entire response was buffered. - if ( did_action( 'wp_footer' ) ) { - $boundary_element->parentNode->insertBefore( $dom->createComment( $boundary_comment_text ), $boundary_element ); - $boundary_element->parentNode->removeChild( $boundary_element ); // Only relevant to serve in the header fragment. - - /** - * Allow plugins to use their own means of serializing the DOM to an HTML string. - * - * This is needed because PHP versions various issues with serializing HTML. - * - * @since 0.2 - * @see AMP_DOM_Utils::get_content_from_dom_node() The AMP plugin has a method that accounts for various cases. - * - * @param null $pre The serialized HTML. Plugins should override this to short-circuit DOMDocument::saveHTML() from being called. - * @param DOMDocument $dom The document to be serialized. - */ - $serialized = apply_filters( 'pre_wp_service_worker_serialize_stream_fragment', null, $dom ); - if ( null === $serialized ) { - $serialized = $dom->saveHTML(); - } - - $token_pos = strpos( $serialized, $search ); - if ( false === $token_pos ) { - return new WP_Error( 'fragment_boundary_not_found' ); - } - $response .= substr( $serialized, $token_pos + strlen( $search ) ); - } - - return $response; -} - /** * Preserve stream fragment query param on canonical redirects. * From 51fd288bff4f3bbd470500b440b31ac9e2b991dd Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 17 Oct 2018 22:49:13 -0700 Subject: [PATCH 19/25] Finish eliminating dependency on AMP --- ...ce-worker-navigation-routing-component.php | 191 +++++++----------- .../js/service-worker-stream-combiner.js | 8 +- 2 files changed, 73 insertions(+), 126 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 3a1f86db9..b4fd77d78 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -35,7 +35,7 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke * @since 0.2 * @var string */ - const STREAM_COMBINE_DEFINE_SCRIPT_ID = 'wp-stream-combine-function'; + const STREAM_COMBINE_DEFINE_SCRIPT_ID = 'wp-stream-define-combine-function'; /** * ID for script element that contains the stream combine function invocation. @@ -43,7 +43,7 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke * @since 0.2 * @var string */ - const STREAM_COMBINE_INVOKE_SCRIPT_ID = 'wp-stream-combine-function'; + const STREAM_COMBINE_INVOKE_SCRIPT_ID = 'wp-stream-invoke-combine-function'; /** * ID for the stream boundary element. @@ -53,20 +53,6 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ const STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID = 'wp-stream-fragment-boundary'; - /** - * Start stream boundary. - * - * @var string - */ - const START_STREAM_BOUNDARY_COMMENT = 'WP_BEGIN_STREAM_BOUNDARY'; - - /** - * End stream boundary comment. - * - * @var string - */ - const END_STREAM_BOUNDARY_COMMENT = 'WP_END_STREAM_BOUNDARY'; - /** * Internal storage for replacements to make in the error response handling script. * @@ -92,7 +78,6 @@ public static function is_streaming_header() { * This element is also used to demarcate the header (head) from the body (tail). * * @since 2.0 - * @todo Consider adding a comment before and after the boundary to make it easier for non-DOM location. Do we need this? * * @param string $loading_content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. */ @@ -108,27 +93,35 @@ public static function do_stream_boundary( $loading_content = '' ) { $is_header = 'header' === $stream_fragment; -// printf( '', self::START_STREAM_BOUNDARY_COMMENT ); // WPCS: XSS OK. + printf( '
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) ); if ( 'header' === $stream_fragment ) { - printf( '
', esc_attr( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ) ); if ( ! $loading_content ) { $loading_content = esc_html__( 'Loading…', 'pwa' ); } echo $loading_content; // WPCS: XSS OK. - echo '
'; } + echo '
'; if ( $is_header ) { + $script = file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ); // phpcs:ignore + $vars = array( + 'STREAM_COMBINE_INVOKE_SCRIPT_ID' => wp_json_encode( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ), + 'STREAM_COMBINE_DEFINE_SCRIPT_ID' => wp_json_encode( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ), + 'STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID' => wp_json_encode( self::STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ), + ); + $script = str_replace( + array_keys( $vars ), + array_values( $vars ), + $script + ); + printf( '', esc_attr( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ), - file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore - ); + $script + ); // WPCS: XSS OK. } - // @todo We don't need this really because we can just use STREAM_COMBINE_DEFINE_SCRIPT_ID as the marker. -// printf( '', self::END_STREAM_BOUNDARY_COMMENT ); // WPCS: XSS OK. - // Short-circuit the response when requesting the header since there is nothing left to stream. if ( $is_header ) { exit; @@ -151,11 +144,7 @@ public static function do_stream_boundary( $loading_content = '' ) { if ( ! $result ) { wp_die( esc_html__( 'Failed to turn header into document.', 'pwa' ) ); } - $response = self::prepare_stream_body_fragment( $dom ); - if ( is_wp_error( $response ) ) { - wp_die( esc_html( $response->get_error_message() ) ); - } - + $response = self::get_header_combine_invoke_script( $dom, true ); echo $response; // WPCS: XSS OK. } } @@ -178,118 +167,74 @@ public static function start_output_buffering_stream_fragment() { } /** - * Prepare stream header fragment. - * - * @since 0.2 - * - * @param DOMDocument $dom Document. - * @return string|WP_Error Header response or error. - */ -// public static function prepare_stream_header_fragment( $dom ) { -// $serialized = "\n"; -// $serialized .= AMP_DOM_Utils::get_content_from_dom_node( $dom, $dom->documentElement ); -// $token_pos = strpos( $serialized, sprintf( '', self::END_STREAM_BOUNDARY_COMMENT ) ); -// if ( false === $token_pos ) { -// return new WP_Error( 'fragment_boundary_not_found' ); -// } -// $response = substr( $serialized, 0, $token_pos ); -// $response .= sprintf( -// '', -// esc_attr( self::STREAM_COMBINE_DEFINE_SCRIPT_ID ), -// file_get_contents( PWA_PLUGIN_DIR . '/wp-includes/js/service-worker-stream-combiner.js' ) // phpcs:ignore -// ); -// return $response; -// } - - /** - * Prepare stream body fragment. + * Get script for adding to the beginning of the body fragment to combine it with the header. * * @since 0.2 * - * @param DOMDocument $dom Document. - * @return string|WP_Error Body response or error. + * @param DOMDocument $dom Document. + * @param bool $serialized Whether to return the script as HTML (true) or a DOM element (false). + * @return DOMElement|string DOM element or HTML string for script element containing header data. */ - public static function prepare_stream_body_fragment( $dom ) { - - // Obtain body fragment. + public static function get_header_combine_invoke_script( $dom, $serialized = true ) { $data = array( // @todo Add root_attributes? 'head_nodes' => array(), 'body_attributes' => array(), ); $head = $dom->getElementsByTagName( 'head' )->item( 0 ); - if ( ! $head ) { - return new WP_Error( 'no_head' ); - } - foreach ( $head->childNodes as $node ) { - if ( $node instanceof DOMElement ) { - if ( 'noscript' === $node->nodeName ) { - continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated. - } - $element = array( - $node->nodeName, - null, - ); - if ( $node->hasAttributes() ) { - $element[1] = array(); - foreach ( $node->attributes as $attribute ) { - $element[1][ $attribute->nodeName ] = $attribute->nodeValue; + if ( $head ) { + foreach ( $head->childNodes as $node ) { + if ( $node instanceof DOMElement ) { + if ( 'noscript' === $node->nodeName ) { + continue; // Obviously noscript will never be relevant to synchronize since it will never be evaluated. } + $element = array( + $node->nodeName, + null, + ); + if ( $node->hasAttributes() ) { + $element[1] = array(); + foreach ( $node->attributes as $attribute ) { + $element[1][ $attribute->nodeName ] = $attribute->nodeValue; + } + } + if ( $node->firstChild instanceof DOMText ) { + $element[] = $node->firstChild->nodeValue; + } + $data['head_nodes'][] = $element; + } elseif ( $node instanceof DOMComment ) { + $data['head_nodes'][] = array( + '#comment', + $node->nodeValue, + ); } - if ( $node->firstChild instanceof DOMText ) { - $element[] = $node->firstChild->nodeValue; - } - $data['head_nodes'][] = $element; - } elseif ( $node instanceof DOMComment ) { - $data['head_nodes'][] = array( - '#comment', - $node->nodeValue, - ); } } - $body = $dom->getElementsByTagName( 'body' )->item( 0 ); - if ( ! $body ) { - return new WP_Error( 'no_body' ); - } - foreach ( $body->attributes as $attribute ) { - $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; - } - // @todo Also obtain classes used in nav menus. - $response = sprintf( - '', - esc_attr( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ), - wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. - ); - - // Include rest of body after the entire response was buffered. - if ( did_action( 'wp_footer' ) ) { - /** - * Allow plugins to use their own means of serializing the DOM to an HTML string. - * - * This is needed because PHP versions various issues with serializing HTML. - * - * @since 0.2 - * @see AMP_DOM_Utils::get_content_from_dom_node() The AMP plugin has a method that accounts for various cases. - * - * @param null $pre The serialized HTML. Plugins should override this to short-circuit DOMDocument::saveHTML() from being called. - * @param DOMDocument $dom The document to be serialized. - */ - $serialized = apply_filters( 'pre_wp_service_worker_serialize_stream_fragment', null, $dom ); - if ( null === $serialized ) { - $serialized = $dom->saveHTML(); - } - - $search = sprintf( '', self::END_STREAM_BOUNDARY_COMMENT ); - $token_pos = strpos( $serialized, $search ); - if ( false === $token_pos ) { - return new WP_Error( 'fragment_boundary_not_found' ); + $body = $dom->getElementsByTagName( 'body' )->item( 0 ); + if ( $body ) { + foreach ( $body->attributes as $attribute ) { + $data['body_attributes'][ $attribute->nodeName ] = $attribute->nodeValue; } - $response .= substr( $serialized, $token_pos + strlen( $search ) ); } - return $response; + if ( $serialized ) { + return sprintf( + '', + esc_attr( self::STREAM_COMBINE_INVOKE_SCRIPT_ID ), + wp_json_encode( $data, JSON_PRETTY_PRINT ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. + ); + } else { + $script = $dom->createElement( 'script' ); + $script->setAttribute( 'id', self::STREAM_COMBINE_INVOKE_SCRIPT_ID ); + $script->appendChild( + $dom->createTextNode( + sprintf( 'wpStreamCombine( %s )', wp_json_encode( $data, JSON_PRETTY_PRINT ) ) // phpcs:ignore PHPCompatibility.PHP.NewConstants.json_pretty_printFound -- Defined in core. + ) + ); + return $script; + } } /** diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 177519e73..520e2d3b9 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -1,3 +1,5 @@ +/*global STREAM_COMBINE_INVOKE_SCRIPT_ID, STREAM_COMBINE_DEFINE_SCRIPT_ID, STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID */ + "use strict"; /* This JS file will be added as an inline script in a stream header fragment response. */ /* This file currently uses JS features which are compatible with Chrome 40 (Googlebot). */ @@ -124,9 +126,9 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ /* Purge all traces of the stream combination logic to ensure the AMP validator doesn't complain at runtime. */ const removedElements = [ - 'wp-stream-fragment-boundary', - 'wp-stream-combine-function', - 'wp-stream-combine-call' + STREAM_COMBINE_INVOKE_SCRIPT_ID, + STREAM_COMBINE_DEFINE_SCRIPT_ID, + STREAM_FRAGMENT_BOUNDARY_ELEMENT_ID ]; for ( const elementId of removedElements ) { const element = document.getElementById( elementId ); From 9855b53271ccc84886211cbb759615906c03d91b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 17 Oct 2018 23:01:09 -0700 Subject: [PATCH 20/25] Skip registering query var for fragment since causes front-page query problem --- wp-includes/class-wp.php | 1 - ...ce-worker-navigation-routing-component.php | 27 +++++++++++++++---- wp-includes/service-workers.php | 4 +-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/wp-includes/class-wp.php b/wp-includes/class-wp.php index 725471f1d..8a1a99a74 100644 --- a/wp-includes/class-wp.php +++ b/wp-includes/class-wp.php @@ -17,6 +17,5 @@ function pwa_add_error_template_query_var() { global $wp; $wp->add_query_var( 'wp_error_template' ); $wp->add_query_var( WP_Service_Workers::QUERY_VAR ); - $wp->add_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); } add_action( 'init', 'pwa_add_error_template_query_var' ); diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index b4fd77d78..fc8100ce9 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -61,13 +61,31 @@ class WP_Service_Worker_Navigation_Routing_Component implements WP_Service_Worke */ protected $replacements = array(); + /** + * Get stream fragment query var. + * + * @since 0.2 + * + * @return string|null Stream fragment name or null if not requested. + */ + public static function get_stream_fragment_query_var() { + if ( ! isset( $_GET[ self::STREAM_FRAGMENT_QUERY_VAR ] ) ) { // WPCS: CSRF OK. + return null; + } + $stream_fragment = wp_unslash( $_GET[ self::STREAM_FRAGMENT_QUERY_VAR ] ); // WPCS: CSRF OK. + if ( in_array( $stream_fragment, array( 'header', 'body' ), true ) ) { + return $stream_fragment; + } + return null; + } + /** * Determine whether the streaming header is being served. * * @return bool Whether streaming header is being served. */ public static function is_streaming_header() { - return current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); + return current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === self::get_stream_fragment_query_var(); } /** @@ -86,7 +104,7 @@ public static function do_stream_boundary( $loading_content = '' ) { _doing_it_wrong( __METHOD__, esc_html__( 'Failed to add "service_worker_streaming" theme support.', 'pwa' ), '0.2' ); return; } - $stream_fragment = get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); + $stream_fragment = self::get_stream_fragment_query_var(); if ( ! $stream_fragment ) { return; } @@ -160,8 +178,7 @@ public static function start_output_buffering_stream_fragment() { if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { return; } - $stream_fragment = get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ); - if ( 'body' === $stream_fragment ) { + if ( 'body' === self::get_stream_fragment_query_var() ) { ob_start(); } } @@ -245,7 +262,7 @@ public static function get_header_combine_invoke_script( $dom, $serialized = tru * @return string Title. */ public static function filter_title_for_streaming_header( $title ) { - if ( current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === get_query_var( self::STREAM_FRAGMENT_QUERY_VAR ) ) { + if ( current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === self::get_stream_fragment_query_var() ) { $title = __( 'Loading...', 'pwa' ); } return $title; diff --git a/wp-includes/service-workers.php b/wp-includes/service-workers.php index e9fd68292..f6a80468e 100644 --- a/wp-includes/service-workers.php +++ b/wp-includes/service-workers.php @@ -328,9 +328,9 @@ function wp_disable_script_concatenation() { * @return string URL to be redirected. */ function wp_service_worker_fragment_redirect_old_slug_to_new_url( $link ) { - $fragment = get_query_var( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR ); + $fragment = WP_Service_Worker_Navigation_Routing_Component::get_stream_fragment_query_var(); if ( $fragment ) { - $link = add_query_arg( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR, sanitize_key( $fragment ), $link ); + $link = add_query_arg( WP_Service_Worker_Navigation_Routing_Component::STREAM_FRAGMENT_QUERY_VAR, $fragment, $link ); } return $link; } From 9bfe7cc92f922bb4f2e7485db393a0ca418de83c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Oct 2018 12:35:40 -0700 Subject: [PATCH 21/25] Improve method ordering and phpdoc --- ...ce-worker-navigation-routing-component.php | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index fc8100ce9..9bc7e4381 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -79,15 +79,49 @@ public static function get_stream_fragment_query_var() { return null; } + /** + * Start output buffering for obtaining a stream fragment. + * + * This runs at template_redirect. If the theme does not support streaming or the body fragment is not requested, + * then this function does nothing. + * + * @since 0.2 + * @see WP_Service_Worker_Navigation_Routing_Component::do_stream_boundary() Which reads the output buffer. + */ + public static function start_output_buffering_stream_fragment() { + if ( current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'body' === self::get_stream_fragment_query_var() ) { + ob_start(); + } + } + /** * Determine whether the streaming header is being served. * + * This is useful to conditionally output styles that are specific to the header fragment (such as a loading progress bar). + * + * @since 0.2 + * * @return bool Whether streaming header is being served. */ public static function is_streaming_header() { return current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === self::get_stream_fragment_query_var(); } + /** + * Filter the title for the streaming header. + * + * @since 0.2 + * + * @param string $title Title. + * @return string Title. + */ + public static function filter_title_for_streaming_header( $title ) { + if ( self::is_streaming_header() ) { + $title = __( 'Loading...', 'pwa' ); + } + return $title; + } + /** * Add loading indicator for responses streamed from the service worker. * @@ -97,7 +131,7 @@ public static function is_streaming_header() { * * @since 2.0 * - * @param string $loading_content Content to display in the boundary. By default it is "Loading" but it could also be a placeholder. + * @param string $loading_content Content to display in the boundary. By default it is "Loading" but it could also be a skeleton placeholder. May contain markup. */ public static function do_stream_boundary( $loading_content = '' ) { if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { @@ -145,7 +179,7 @@ public static function do_stream_boundary( $loading_content = '' ) { exit; } - // Handle serving the body. Normally it is output-buffered here. + // Handle serving the body. Normally it is output-buffered here. A plugin can disable the default output buffering to handle it later. $is_header_buffered = ( false !== has_action( 'template_redirect', 'WP_Service_Worker_Navigation_Routing_Component::start_output_buffering_stream_fragment' ) && @@ -166,22 +200,6 @@ public static function do_stream_boundary( $loading_content = '' ) { echo $response; // WPCS: XSS OK. } } - /** - * Start output buffering for obtaining a stream fragment. - * - * This runs at template_redirect. If the theme dues not support streaming or the body fragment is not requested, - * then this function does nothing. - * - * @since 0.2 - */ - public static function start_output_buffering_stream_fragment() { - if ( ! current_theme_supports( self::STREAM_THEME_SUPPORT ) ) { - return; - } - if ( 'body' === self::get_stream_fragment_query_var() ) { - ob_start(); - } - } /** * Get script for adding to the beginning of the body fragment to combine it with the header. @@ -194,7 +212,7 @@ public static function start_output_buffering_stream_fragment() { */ public static function get_header_combine_invoke_script( $dom, $serialized = true ) { $data = array( - // @todo Add root_attributes? + // @todo Add html_attributes? 'head_nodes' => array(), 'body_attributes' => array(), ); @@ -228,7 +246,7 @@ public static function get_header_combine_invoke_script( $dom, $serialized = tru } } - // @todo Also obtain classes used in nav menus. + // @todo Also obtain classes used in nav menus to then synchronize? $body = $dom->getElementsByTagName( 'body' )->item( 0 ); if ( $body ) { foreach ( $body->attributes as $attribute ) { @@ -254,20 +272,6 @@ public static function get_header_combine_invoke_script( $dom, $serialized = tru } } - /** - * Filter the title for the streaming header. - * - * @since 0.2 - * @param string $title Title. - * @return string Title. - */ - public static function filter_title_for_streaming_header( $title ) { - if ( current_theme_supports( self::STREAM_THEME_SUPPORT ) && 'header' === self::get_stream_fragment_query_var() ) { - $title = __( 'Loading...', 'pwa' ); - } - return $title; - } - /** * Adds the component functionality to the service worker. * From 3b11698c813dbfe70bfb037933ebd23023a052cf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Oct 2018 15:47:46 -0700 Subject: [PATCH 22/25] Add missing jsdoc, remove bad DOMDocument arg, clean up --- .../class-wp-service-worker-navigation-routing-component.php | 2 +- wp-includes/js/service-worker-navigation-routing.js | 3 +-- wp-includes/js/service-worker-stream-combiner.js | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index 9bc7e4381..a5723159e 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -189,7 +189,7 @@ public static function do_stream_boundary( $loading_content = '' ) { $header_html = ob_get_clean(); $libxml_use_errors = libxml_use_internal_errors( true ); $header_html = preg_replace( '##s', '', $header_html ); // Some libxml versions croak at noscript in head. - $dom = new DOMDocument( $header_html ); + $dom = new DOMDocument(); $result = $dom->loadHTML( $header_html ); libxml_clear_errors(); libxml_use_internal_errors( $libxml_use_errors ); diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 0bdf5ceb8..5463f4b7f 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -11,7 +11,7 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR if ( response.redirected ) { const redirectedUrl = new URL( response.url ); redirectedUrl.searchParams.delete( STREAM_HEADER_FRAGMENT_QUERY_VAR ); - const script = ``; + const script = ``; return response.text().then( ( body ) => { return new Response( script + body ); } ); @@ -88,7 +88,6 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR .catch( sendOfflineResponse ), ]); - // @todo Handle error case. return stream.response; } else { return fetch( event.request ) diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index 520e2d3b9..b385eec82 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -18,8 +18,10 @@ function wpStreamCombine( data ) { /* eslint-disable-line no-unused-vars */ /** * Determine if a given element matches the nodeData coming from the body fragment. * - * Returns true if the element name is the same and its attributes are equal. + * Returns true if the element name is the same and its attributes are equal. Node textContent is not matched. * + * @param {Element} element - Element in head to compare. + * @param {Object} newElementData - Head node data to compare with. * @returns {boolean} Matching. */ const isElementMatchingData = ( element, newElementData ) => { From 7da958d287a540399ada583b16d88d073b9214d6 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Oct 2018 15:58:21 -0700 Subject: [PATCH 23/25] Prevent sending streamed responses when navigation preload happens --- .../js/service-worker-navigation-routing.js | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 5463f4b7f..642341fd3 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -6,6 +6,19 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR async function ( { event } ) { const { url } = event.request; + let responsePreloaded = false; + + const canStreamResponse = () => { + if ( ! isStreamingResponses || responsePreloaded ) { + return false; + } + const url = new URL( event.request.url ); + return ! ( + /\.php$/.test( url.pathname ) || + url.searchParams.has( STREAM_HEADER_FRAGMENT_QUERY_VAR ) + ); + }; + const handleResponse = ( response ) => { if ( response.status < 500 ) { if ( response.redirected ) { @@ -41,11 +54,11 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR channel.close(); }, 30 * 1000 ); - return caches.match( isStreamingResponses ? ERROR_500_BODY_FRAGMENT_URL : ERROR_500_URL ); + return caches.match( canStreamResponse() ? ERROR_500_BODY_FRAGMENT_URL : ERROR_500_URL ); }; const sendOfflineResponse = () => { - return caches.match( isStreamingResponses ? ERROR_OFFLINE_BODY_FRAGMENT_URL : ERROR_OFFLINE_URL ); + return caches.match( canStreamResponse() ? ERROR_OFFLINE_BODY_FRAGMENT_URL : ERROR_OFFLINE_URL ); }; /* @@ -56,22 +69,16 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR try { const response = await event.preloadResponse; if ( response ) { + responsePreloaded = true; return handleResponse( response ); } } catch ( error ) { + responsePreloaded = true; return sendOfflineResponse(); } } - const canStreamResponse = () => { - const url = new URL( event.request.url ); - return ! ( - /\.php$/.test( url.pathname ) || - url.searchParams.has( STREAM_HEADER_FRAGMENT_QUERY_VAR ) - ); - }; - - if ( isStreamingResponses && canStreamResponse() ) { + if ( canStreamResponse() ) { const streamHeaderFragmentURL = STREAM_HEADER_FRAGMENT_URL; const precacheStrategy = wp.serviceWorker.strategies.cacheFirst({ cacheName: wp.serviceWorker.core.cacheNames.precache, From 270ab2ccd5643e4421fbc76c8c7200f26cdf29dc Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Oct 2018 22:04:42 -0700 Subject: [PATCH 24/25] Expand pattern blacklist for non-template URLs --- ...ce-worker-navigation-routing-component.php | 36 +++++++++++++++---- .../js/service-worker-navigation-routing.js | 16 ++++----- .../js/service-worker-stream-combiner.js | 2 +- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php index a5723159e..4238ee1fe 100644 --- a/wp-includes/components/class-wp-service-worker-navigation-routing-component.php +++ b/wp-includes/components/class-wp-service-worker-navigation-routing-component.php @@ -415,23 +415,47 @@ public function serve( WP_Service_Worker_Scripts $scripts ) { } } - $blacklist_patterns = array(); - if ( ! is_admin() ) { - $blacklist_patterns[] = '^' . preg_quote( untrailingslashit( wp_parse_url( admin_url(), PHP_URL_PATH ) ), '/' ) . '($|\?.*|/.*)'; - } - $this->replacements = array( 'ERROR_OFFLINE_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? $offline_error_precache_entry['url'] : null ), 'ERROR_OFFLINE_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $offline_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $offline_error_precache_entry['url'] ) : null ), 'ERROR_500_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? $server_error_precache_entry['url'] : null ), 'ERROR_500_BODY_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $server_error_precache_entry['url'] ) ? add_query_arg( self::STREAM_FRAGMENT_QUERY_VAR, 'body', $server_error_precache_entry['url'] ) : null ), 'STREAM_HEADER_FRAGMENT_URL' => wp_service_worker_json_encode( isset( $streaming_header_precache_entry['url'] ) ? $streaming_header_precache_entry['url'] : null ), - 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $blacklist_patterns ), + 'BLACKLIST_PATTERNS' => wp_service_worker_json_encode( $this->get_blacklist_patterns() ), 'SHOULD_STREAM_RESPONSE' => wp_service_worker_json_encode( $should_stream_response ), 'STREAM_HEADER_FRAGMENT_QUERY_VAR' => wp_service_worker_json_encode( self::STREAM_FRAGMENT_QUERY_VAR ), ); } + /** + * Get blacklist patterns for routes to exclude from navigation route handling. + * + * @since 0.2 + * @todo This list should probably be filterable. + * + * @return array Route regular expressions. + */ + public function get_blacklist_patterns() { + $blacklist_patterns = array(); + + // Exclude admin URLs. + $blacklist_patterns[] = '^' . preg_quote( untrailingslashit( wp_parse_url( admin_url(), PHP_URL_PATH ) ), '/' ) . '($|\?.*|/.*)'; + + // Exclude REST API. + $blacklist_patterns[] = '^' . preg_quote( wp_parse_url( get_rest_url(), PHP_URL_PATH ), '/' ) . '.*'; + + // Exclude PHP files (e.g. wp-login.php). + $blacklist_patterns[] = '[^\?]*.\.php($|\?.*)'; + + // Exclude service worker and stream fragment requests (to ease debugging). + $blacklist_patterns[] = '.*\?(.*&)?(' . join( '|', array( self::STREAM_FRAGMENT_QUERY_VAR, WP_Service_Workers::QUERY_VAR ) ) . ')='; + + // Exclude feed requests. + $blacklist_patterns[] = '[^\?]*\/feed\/(\w+\/)?$'; + + return $blacklist_patterns; + } + /** * Gets the priority this component should be hooked into the service worker action with. * diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 642341fd3..566746b15 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -9,14 +9,7 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR let responsePreloaded = false; const canStreamResponse = () => { - if ( ! isStreamingResponses || responsePreloaded ) { - return false; - } - const url = new URL( event.request.url ); - return ! ( - /\.php$/.test( url.pathname ) || - url.searchParams.has( STREAM_HEADER_FRAGMENT_QUERY_VAR ) - ); + return isStreamingResponses && ! responsePreloaded; }; const handleResponse = ( response ) => { @@ -24,7 +17,12 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR if ( response.redirected ) { const redirectedUrl = new URL( response.url ); redirectedUrl.searchParams.delete( STREAM_HEADER_FRAGMENT_QUERY_VAR ); - const script = ``; + const script = ` + + `; return response.text().then( ( body ) => { return new Response( script + body ); } ); diff --git a/wp-includes/js/service-worker-stream-combiner.js b/wp-includes/js/service-worker-stream-combiner.js index b385eec82..f95472640 100644 --- a/wp-includes/js/service-worker-stream-combiner.js +++ b/wp-includes/js/service-worker-stream-combiner.js @@ -7,7 +7,7 @@ /** * Apply the stream body data to the stream header. * - * @param {Array} data Data. + * @param {Array} data - Data collected from the DOMDocument prior to the stream boundary. * @param {Array} data.head_nodes - Nodes in HEAD. * @param {Object} data.body_attributes - Attributes on body. */ From 65d74cad2a93b3ad3ecebf465c06793f84219c9d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 18 Oct 2018 22:16:47 -0700 Subject: [PATCH 25/25] Fix copying request props --- .../js/service-worker-navigation-routing.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/wp-includes/js/service-worker-navigation-routing.js b/wp-includes/js/service-worker-navigation-routing.js index 566746b15..1b4f844ef 100644 --- a/wp-includes/js/service-worker-navigation-routing.js +++ b/wp-includes/js/service-worker-navigation-routing.js @@ -84,7 +84,22 @@ wp.serviceWorker.routing.registerRoute( new wp.serviceWorker.routing.NavigationR const url = new URL( event.request.url ); url.searchParams.append( STREAM_HEADER_FRAGMENT_QUERY_VAR, 'body' ); - const request = new Request( url.toString(), {...event.request} ); + const init = { + mode: 'same-origin' + }; + const copiedProps = [ + 'method', + 'headers', + 'credentials', + 'cache', + 'redirect', + 'referrer', + 'integrity', + ]; + for ( const initProp of copiedProps ) { + init[ initProp ] = event.request[ initProp ]; + } + const request = new Request( url.toString(), init ); const stream = wp.serviceWorker.streams.concatenateToResponse([ precacheStrategy.makeRequest({ request: streamHeaderFragmentURL }), // @todo This should be able to vary based on the request.url. No: just don't allow in paired mode.