@automattic/newspack-blocks 4.34.1 → 4.35.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +28 -0
  3. package/dist/editor-rtl.css +17 -17
  4. package/dist/editor.asset.php +1 -1
  5. package/dist/editor.css +17 -17
  6. package/dist/editor.js +22 -22
  7. package/dist/modal.asset.php +1 -1
  8. package/dist/modal.js +1 -1
  9. package/includes/class-newspack-blocks-api.php +94 -0
  10. package/includes/class-newspack-blocks-caching.php +1 -1
  11. package/includes/class-newspack-blocks.php +2 -0
  12. package/languages/newspack-blocks-de_DE-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  13. package/languages/newspack-blocks-de_DE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  14. package/languages/newspack-blocks-de_DE.po +15 -10
  15. package/languages/newspack-blocks-es_ES-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  16. package/languages/newspack-blocks-es_ES-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  17. package/languages/newspack-blocks-es_ES.po +15 -10
  18. package/languages/newspack-blocks-fr_BE-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  19. package/languages/newspack-blocks-fr_BE-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  20. package/languages/newspack-blocks-fr_BE.po +15 -10
  21. package/languages/newspack-blocks-nb_NO-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  22. package/languages/newspack-blocks-nb_NO-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  23. package/languages/newspack-blocks-nb_NO.po +15 -10
  24. package/languages/newspack-blocks-pt_PT-17dfe823d00514d2459851e9b0f738b1.json +1 -0
  25. package/languages/newspack-blocks-pt_PT-53e2a1d5945b8d2b1c35e81ae1e532f3.json +1 -1
  26. package/languages/newspack-blocks-pt_PT.po +15 -10
  27. package/languages/newspack-blocks.pot +17 -11
  28. package/newspack-blocks.php +2 -2
  29. package/package.json +6 -6
  30. package/src/blocks/carousel/edit.js +1 -1
  31. package/src/blocks/homepage-articles/class-wp-rest-newspack-articles-controller.php +47 -0
  32. package/src/blocks/homepage-articles/store.js +101 -38
  33. package/src/blocks/homepage-articles/store.test.js +189 -0
  34. package/src/modal-checkout/modal.js +11 -1
  35. package/src/modal-checkout/utils.js +33 -0
  36. package/src/modal-checkout/utils.test.js +67 -1
  37. package/vendor/composer/installed.php +2 -2
@@ -80,6 +80,53 @@ class WP_REST_Newspack_Articles_Controller extends WP_REST_Controller {
80
80
  ]
81
81
  );
82
82
 
83
+ // Endpoint to get articles for every block on a page in one request, in document order.
84
+ register_rest_route(
85
+ $this->namespace,
86
+ '/newspack-blocks-posts-batch',
87
+ [
88
+ 'methods' => \WP_REST_Server::CREATABLE,
89
+ 'callback' => [ 'Newspack_Blocks_API', 'posts_batch_endpoint' ],
90
+ 'args' => [
91
+ 'queries' => [
92
+ 'type' => 'array',
93
+ 'required' => true,
94
+ 'maxItems' => Newspack_Blocks_API::POSTS_BATCH_MAX_QUERIES,
95
+ // Core fills defaults for top-level args only, so the handler supplies its
96
+ // own for these two rather than relying on the schema.
97
+ 'items' => [
98
+ 'type' => 'object',
99
+ 'properties' => [
100
+ 'clientId' => [
101
+ 'type' => 'string',
102
+ 'required' => true,
103
+ ],
104
+ 'postsQuery' => [
105
+ 'type' => 'object',
106
+ ],
107
+ 'deduplicate' => [
108
+ 'type' => 'boolean',
109
+ ],
110
+ ],
111
+ ],
112
+ ],
113
+ 'exclude' => [ // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude
114
+ 'type' => 'array',
115
+ 'items' => [
116
+ 'type' => 'integer',
117
+ ],
118
+ // Every deduplicating query carries this list plus everything the batch has
119
+ // matched so far, so an unbounded list here is work every query pays for.
120
+ 'maxItems' => Newspack_Blocks_API::POSTS_BATCH_MAX_EXCLUDE,
121
+ 'default' => [],
122
+ ],
123
+ ],
124
+ 'permission_callback' => function() {
125
+ return current_user_can( 'edit_posts' );
126
+ },
127
+ ]
128
+ );
129
+
83
130
  // Endpoint to get articles in the editor, in specific posts mode.
84
131
  register_rest_route(
85
132
  $this->namespace,
@@ -11,6 +11,7 @@ import { set } from 'lodash';
11
11
  */
12
12
  import { register, select } from '@wordpress/data';
13
13
  import apiFetch from '@wordpress/api-fetch';
14
+ import { __ } from '@wordpress/i18n';
14
15
  import { addQueryArgs } from '@wordpress/url';
15
16
 
16
17
  /**
@@ -86,28 +87,103 @@ const POSTS_QUERIES_CACHE = {};
86
87
  const createCacheKey = JSON.stringify;
87
88
 
88
89
  /**
89
- * Get posts for a single block.
90
+ * The query a block's posts are cached under. A deduplicating block's result depends on
91
+ * the posts shown above it, so the exclusion list is part of its key.
92
+ *
93
+ * @param {Object} block an object with a postsQuery and a deduplicate flag
94
+ * @param {Array} exclude IDs of posts already shown above the block
95
+ * @return {Object} posts query
96
+ */
97
+ const effectiveQuery = ( block, exclude ) => ( block.deduplicate ? { ...block.postsQuery, exclude } : block.postsQuery );
98
+
99
+ /**
100
+ * Fetch posts for blocks in document order, carrying the exclusion list from each
101
+ * deduplicating block to the next.
102
+ *
103
+ * Blocks answered from the cache are dispatched without a request. From the first block
104
+ * that isn't cached, the rest go to the batch endpoint, which applies the exclusion list
105
+ * server-side, so a page costs one request instead of one per block. When only one block
106
+ * needs posts, it uses the single-block endpoint instead.
90
107
  *
91
108
  * @yield
92
- * @param {Object} block an object with a postsQuery and a clientId
109
+ * @param {Array} blockQueries objects with clientId, postsQuery and deduplicate, in document order
110
+ * @param {Array} exclude IDs of posts to exclude from the first deduplicating block
93
111
  */
94
- function* getPostsForBlock( block ) {
95
- const cacheKey = createCacheKey( block.postsQuery );
96
- const restUrl = window.newspack_blocks_data.posts_rest_url;
97
- let posts = POSTS_QUERIES_CACHE[ cacheKey ];
98
- if ( posts === undefined ) {
99
- const url = addQueryArgs( restUrl, {
100
- ...block.postsQuery,
101
- // `context=edit` is needed, so that custom REST fields are returned.
102
- context: 'edit',
103
- } );
104
- posts = yield call( apiFetch, { url } );
105
- POSTS_QUERIES_CACHE[ cacheKey ] = posts;
106
- }
112
+ export function* fetchPostsForBlocks( blockQueries, exclude ) {
113
+ const { posts_rest_url: singleUrl, posts_batch_rest_url: url } = window.newspack_blocks_data;
114
+ // Localized as a string, and the server enforces its own limit either way.
115
+ const maxQueries = Number( window.newspack_blocks_data.posts_batch_max_queries );
116
+ const pending = [ ...blockQueries ];
117
+
118
+ const showPosts = function* ( block, posts ) {
119
+ POSTS_QUERIES_CACHE[ createCacheKey( effectiveQuery( block, exclude ) ) ] = posts;
120
+ yield put( { type: 'UPDATE_BLOCK_POSTS', clientId: block.clientId, posts } );
121
+ if ( block.deduplicate ) {
122
+ exclude = [ ...exclude, ...posts.map( post => post.id ) ];
123
+ }
124
+ };
125
+
126
+ while ( pending.length ) {
127
+ const cached = POSTS_QUERIES_CACHE[ createCacheKey( effectiveQuery( pending[ 0 ], exclude ) ) ];
128
+ if ( cached !== undefined ) {
129
+ yield* showPosts( pending.shift(), cached );
130
+ continue;
131
+ }
132
+
133
+ const batch = pending.splice( 0, maxQueries );
134
+
135
+ if ( batch.length === 1 ) {
136
+ const [ block ] = batch;
137
+ try {
138
+ const posts = yield call( apiFetch, {
139
+ // `context=edit` is needed, so that custom REST fields are returned.
140
+ url: addQueryArgs( singleUrl, { ...effectiveQuery( block, exclude ), context: 'edit' } ),
141
+ } );
142
+ yield* showPosts( block, posts );
143
+ } catch ( e ) {
144
+ // A failed block adds nothing to the exclusion list, so later blocks can still load.
145
+ yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: block.clientId, error: e.message } );
146
+ }
147
+ continue;
148
+ }
149
+
150
+ let results;
151
+ try {
152
+ results = yield call( apiFetch, {
153
+ url,
154
+ method: 'POST',
155
+ data: {
156
+ exclude,
157
+ queries: batch.map( ( { clientId, postsQuery, deduplicate } ) => ( {
158
+ clientId,
159
+ // `context=edit` is needed, so that custom REST fields are returned.
160
+ postsQuery: { ...postsQuery, context: 'edit' },
161
+ deduplicate,
162
+ } ) ),
163
+ },
164
+ } );
165
+ } catch ( e ) {
166
+ // Without this batch's posts the exclusion list for later blocks is unknown.
167
+ for ( const block of [ ...batch, ...pending ] ) {
168
+ yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: block.clientId, error: e.message } );
169
+ }
170
+ return;
171
+ }
107
172
 
108
- const postsIds = posts.map( post => post.id );
109
- yield put( { type: 'UPDATE_BLOCK_POSTS', clientId: block.clientId, posts } );
110
- return postsIds;
173
+ const resultsByClientId = Object.fromEntries( results.map( result => [ result.clientId, result ] ) );
174
+ for ( const block of batch ) {
175
+ const result = resultsByClientId[ block.clientId ];
176
+ if ( result?.posts ) {
177
+ yield* showPosts( block, result.posts );
178
+ } else {
179
+ yield put( {
180
+ type: 'UPDATE_BLOCK_ERROR',
181
+ clientId: block.clientId,
182
+ error: result?.error || __( 'The posts for this block could not be loaded.', 'newspack-blocks' ),
183
+ } );
184
+ }
185
+ }
186
+ }
111
187
  }
112
188
 
113
189
  /**
@@ -140,33 +216,20 @@ const createFetchPostsSaga = blockNames => {
140
216
 
141
217
  const blocks = recursivelyGetBlocks( getBlocks );
142
218
 
143
- const blockQueries = getBlockQueries( blocks, blockNames );
219
+ const blockQueries = getBlockQueries( blocks, blockNames ).map( block => ( {
220
+ ...block,
221
+ deduplicate: Boolean( shouldDeduplicate( block.clientId ) ),
222
+ } ) );
144
223
 
145
224
  // Use requested specific posts ids as the starting state of exclusion list.
146
- const specificPostsId = blockQueries.reduce( ( acc, { clientId, postsQuery } ) => {
147
- if ( shouldDeduplicate( clientId ) && postsQuery.include ) {
225
+ const specificPostsId = blockQueries.reduce( ( acc, { deduplicate, postsQuery } ) => {
226
+ if ( deduplicate && postsQuery.include ) {
148
227
  acc = [ ...acc, ...postsQuery.include ];
149
228
  }
150
229
  return acc;
151
230
  }, [] );
152
231
 
153
- let exclude = sanitizePostList( [ ...specificPostsId, getCurrentPostId() ] );
154
- while ( blockQueries.length ) {
155
- const nextBlock = blockQueries.shift();
156
- const deduplicate = shouldDeduplicate( nextBlock.clientId );
157
- if ( deduplicate ) {
158
- nextBlock.postsQuery.exclude = exclude;
159
- }
160
- let fetchedPostIds = [];
161
- try {
162
- fetchedPostIds = yield call( getPostsForBlock, nextBlock );
163
- } catch ( e ) {
164
- yield put( { type: 'UPDATE_BLOCK_ERROR', clientId: nextBlock.clientId, error: e.message } );
165
- }
166
- if ( deduplicate ) {
167
- exclude = [ ...exclude, ...fetchedPostIds ];
168
- }
169
- }
232
+ yield call( fetchPostsForBlocks, blockQueries, sanitizePostList( [ ...specificPostsId, getCurrentPostId() ] ) );
170
233
 
171
234
  yield put( { type: 'ENABLE_UI' } );
172
235
  }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { runSaga } from 'redux-saga';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { getQueryArgs } from '@wordpress/url';
10
+
11
+ jest.mock( '@wordpress/api-fetch' );
12
+
13
+ const SINGLE_URL = 'https://example.test/wp-json/newspack-blocks/v1/newspack-blocks-posts';
14
+ const BATCH_URL = 'https://example.test/wp-json/newspack-blocks/v1/newspack-blocks-posts-batch';
15
+
16
+ // Every query draws from posts 1–20, newest first, skipping the posts in its exclusion list.
17
+ const findPosts = ( postsToShow, exclude ) => {
18
+ const ids = [];
19
+ for ( let id = 1; id <= 20 && ids.length < postsToShow; id++ ) {
20
+ if ( ! exclude.includes( id ) ) {
21
+ ids.push( id );
22
+ }
23
+ }
24
+ return ids;
25
+ };
26
+
27
+ // Stands in for both endpoints. The batch endpoint carries the exclusion list from each
28
+ // deduplicating query to the next; the single-block endpoint takes its list from the URL.
29
+ const fakeEndpoints = ( { url, data } ) => {
30
+ if ( ! data ) {
31
+ const { postsToShow, exclude = [] } = getQueryArgs( url );
32
+ return Promise.resolve( findPosts( Number( postsToShow ), exclude.map( Number ) ).map( id => ( { id } ) ) );
33
+ }
34
+ let exclude = [ ...data.exclude ];
35
+ return Promise.resolve(
36
+ data.queries.map( ( { clientId, postsQuery, deduplicate } ) => {
37
+ if ( postsQuery.fail ) {
38
+ return { clientId, error: 'Invalid parameter(s): postsToShow' };
39
+ }
40
+ const ids = findPosts( postsQuery.postsToShow, deduplicate ? exclude : [] );
41
+ if ( deduplicate ) {
42
+ exclude = [ ...exclude, ...ids ];
43
+ }
44
+ return { clientId, posts: ids.map( id => ( { id } ) ) };
45
+ } )
46
+ );
47
+ };
48
+
49
+ const block = ( clientId, postsToShow, extra = {} ) => ( {
50
+ clientId,
51
+ postsQuery: { postsToShow, ...extra },
52
+ deduplicate: true,
53
+ } );
54
+
55
+ describe( 'fetchPostsForBlocks', () => {
56
+ // The query cache lives at module scope, so each test loads a fresh copy of the store,
57
+ // along with the api-fetch mock that copy calls.
58
+ let fetchPostsForBlocks, apiFetch;
59
+
60
+ const run = async ( blocks, exclude = [] ) => {
61
+ const dispatched = [];
62
+ await runSaga( { dispatch: action => dispatched.push( action ) }, fetchPostsForBlocks, blocks, exclude ).toPromise();
63
+ return {
64
+ postsByBlock: Object.fromEntries(
65
+ dispatched
66
+ .filter( action => action.type === 'UPDATE_BLOCK_POSTS' )
67
+ .map( action => [ action.clientId, action.posts.map( post => post.id ) ] )
68
+ ),
69
+ errorsByBlock: Object.fromEntries(
70
+ dispatched.filter( action => action.type === 'UPDATE_BLOCK_ERROR' ).map( action => [ action.clientId, action.error ] )
71
+ ),
72
+ };
73
+ };
74
+
75
+ beforeEach( () => {
76
+ // wp_localize_script casts every scalar to a string, so the limit arrives as one.
77
+ window.newspack_blocks_data = { posts_rest_url: SINGLE_URL, posts_batch_rest_url: BATCH_URL, posts_batch_max_queries: '50' };
78
+ jest.isolateModules( () => {
79
+ apiFetch = require( '@wordpress/api-fetch' ).default;
80
+ ( { fetchPostsForBlocks } = require( './store' ) );
81
+ } );
82
+ apiFetch.mockImplementation( fakeEndpoints );
83
+ } );
84
+
85
+ it( 'loads every block on the page with one request', async () => {
86
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 2 ), block( 'c', 1 ) ], [ 20 ] );
87
+
88
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
89
+ expect( apiFetch.mock.calls[ 0 ][ 0 ] ).toMatchObject( { url: BATCH_URL, method: 'POST', data: { exclude: [ 20 ] } } );
90
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4 ], c: [ 5 ] } );
91
+ } );
92
+
93
+ it( 'asks for custom REST fields', async () => {
94
+ await run( [ block( 'a', 1 ), block( 'b', 1 ) ] );
95
+
96
+ expect( apiFetch.mock.calls[ 0 ][ 0 ].data.queries[ 0 ].postsQuery.context ).toBe( 'edit' );
97
+ } );
98
+
99
+ it( 'uses the single-block request when only one block needs posts', async () => {
100
+ const { postsByBlock } = await run( [ block( 'a', 2 ) ], [ 1 ] );
101
+
102
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
103
+ const [ { url, data, method } ] = apiFetch.mock.calls[ 0 ];
104
+ expect( url.startsWith( SINGLE_URL + '?' ) ).toBe( true );
105
+ expect( getQueryArgs( url ) ).toMatchObject( { context: 'edit', exclude: [ '1' ] } );
106
+ expect( data ).toBeUndefined();
107
+ expect( method ).toBeUndefined();
108
+ expect( postsByBlock ).toEqual( { a: [ 2, 3 ] } );
109
+ } );
110
+
111
+ it( 'fetches only the last block, on its own, when it is the one that changed', async () => {
112
+ await run( [ block( 'a', 2 ), block( 'b', 2 ) ] );
113
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 3 ) ] );
114
+
115
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
116
+ const [ { url, data } ] = apiFetch.mock.calls[ 1 ];
117
+ expect( data ).toBeUndefined();
118
+ expect( getQueryArgs( url ).exclude ).toEqual( [ '1', '2' ] );
119
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4, 5 ] } );
120
+ } );
121
+
122
+ it( 'reports a failed single-block request on that block only', async () => {
123
+ apiFetch.mockRejectedValueOnce( new Error( 'Service unavailable' ) );
124
+
125
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ) ] );
126
+
127
+ expect( errorsByBlock ).toEqual( { a: 'Service unavailable' } );
128
+ expect( postsByBlock ).toEqual( {} );
129
+ } );
130
+
131
+ it( 'reuses cached posts when nothing on the page changed', async () => {
132
+ const blocks = [ block( 'a', 2 ), block( 'b', 2 ) ];
133
+ await run( blocks );
134
+ const { postsByBlock } = await run( blocks );
135
+
136
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
137
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4 ] } );
138
+ } );
139
+
140
+ // Blocks above an edited block keep their cached posts. The edited block and everything
141
+ // below it are re-fetched, starting from the posts the blocks above show.
142
+ it( 'only re-fetches from the first block that changed', async () => {
143
+ await run( [ block( 'a', 2 ), block( 'b', 2 ), block( 'c', 2 ) ] );
144
+ const { postsByBlock } = await run( [ block( 'a', 2 ), block( 'b', 3 ), block( 'c', 2 ) ] );
145
+
146
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
147
+ const { data } = apiFetch.mock.calls[ 1 ][ 0 ];
148
+ expect( data.queries.map( query => query.clientId ) ).toEqual( [ 'b', 'c' ] );
149
+ expect( data.exclude ).toEqual( [ 1, 2 ] );
150
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 3, 4, 5 ], c: [ 6, 7 ] } );
151
+ } );
152
+
153
+ it( 'keeps the posts of a block without deduplication available to the blocks below it', async () => {
154
+ const { postsByBlock } = await run( [ block( 'a', 2 ), { ...block( 'b', 2 ), deduplicate: false }, block( 'c', 2 ) ] );
155
+
156
+ expect( postsByBlock ).toEqual( { a: [ 1, 2 ], b: [ 1, 2 ], c: [ 3, 4 ] } );
157
+ } );
158
+
159
+ it( 'reports a failed query on its own block and still loads the others', async () => {
160
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1, { fail: true } ), block( 'c', 1 ) ] );
161
+
162
+ expect( errorsByBlock ).toEqual( { b: 'Invalid parameter(s): postsToShow' } );
163
+ expect( postsByBlock ).toEqual( { a: [ 1 ], c: [ 2 ] } );
164
+ } );
165
+
166
+ // Later blocks' exclusion lists depend on the failed batch, so they can't be loaded either.
167
+ it( 'reports an error on every remaining block when a batch request fails', async () => {
168
+ window.newspack_blocks_data.posts_batch_max_queries = '2';
169
+ apiFetch.mockImplementationOnce( fakeEndpoints ).mockRejectedValueOnce( new Error( 'Service unavailable' ) );
170
+
171
+ const { postsByBlock, errorsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1 ), block( 'c', 1 ), block( 'd', 1 ), block( 'e', 1 ) ] );
172
+
173
+ expect( postsByBlock ).toEqual( { a: [ 1 ], b: [ 2 ] } );
174
+ expect( errorsByBlock ).toEqual( { c: 'Service unavailable', d: 'Service unavailable', e: 'Service unavailable' } );
175
+ expect( apiFetch ).toHaveBeenCalledTimes( 2 );
176
+ } );
177
+
178
+ it( 'splits a page larger than the batch limit and carries deduplication across batches', async () => {
179
+ window.newspack_blocks_data.posts_batch_max_queries = '2';
180
+
181
+ const { postsByBlock } = await run( [ block( 'a', 1 ), block( 'b', 1 ), block( 'c', 1 ), block( 'd', 1 ), block( 'e', 1 ) ] );
182
+
183
+ expect( apiFetch ).toHaveBeenCalledTimes( 3 );
184
+ expect( apiFetch.mock.calls[ 1 ][ 0 ].data.exclude ).toEqual( [ 1, 2 ] );
185
+ // The last block is left over on its own, so it uses the single-block request.
186
+ expect( getQueryArgs( apiFetch.mock.calls[ 2 ][ 0 ].url ).exclude ).toEqual( [ '1', '2', '3', '4' ] );
187
+ expect( postsByBlock ).toEqual( { a: [ 1 ], b: [ 2 ], c: [ 3 ], d: [ 4 ], e: [ 5 ] } );
188
+ } );
189
+ } );
@@ -11,6 +11,7 @@ import * as a11y from './accessibility.js';
11
11
  */
12
12
  import { manageDismissed, manageOpened } from './analytics';
13
13
  import {
14
+ afterDeferredScripts,
14
15
  domReady,
15
16
  iframeReady,
16
17
  onCheckoutReady,
@@ -980,7 +981,16 @@ domReady( () => {
980
981
  stripCheckoutUrlParams();
981
982
  }
982
983
  };
983
- handleModalCheckoutUrlParams();
984
+ // A click reaches a form long after every script has loaded; the URL trigger
985
+ // fires as soon as this bundle runs. This bundle is async and the reader
986
+ // activation scripts are deferred, so firing here can beat them — and then
987
+ // the checkout opens without the sign-in step and without registering as an
988
+ // overlay, which lets prompts open on top of it. Deferred scripts are done
989
+ // by DOMContentLoaded, so the trigger waits for that rather than queueing on
990
+ // newspackRAS: that queue only flushes once newspack-plugin's reader
991
+ // activation script runs, and the modal needs only WooCommerce, so on a site
992
+ // without newspack-plugin a queued trigger would never fire.
993
+ afterDeferredScripts( handleModalCheckoutUrlParams );
984
994
 
985
995
  /**
986
996
  * Open the modal checkout.
@@ -20,6 +20,39 @@ export function domReady( callback ) {
20
20
  document.addEventListener( 'DOMContentLoaded', callback );
21
21
  }
22
22
 
23
+ /**
24
+ * Run a callback once every deferred script on the page has executed.
25
+ *
26
+ * Deferred scripts run after parsing ends and before DOMContentLoaded, and
27
+ * this bundle is async, so it can execute in between — domReady() only waits
28
+ * for parsing. readyState is `interactive` on both sides of DOMContentLoaded,
29
+ * so in that state navigation timing settles whether the event already fired.
30
+ *
31
+ * @param {Function} callback The function to run.
32
+ * @return {void}
33
+ */
34
+ export function afterDeferredScripts( callback ) {
35
+ const { readyState } = document;
36
+ if ( readyState === 'complete' || ( readyState === 'interactive' && hasDomContentLoadedFired() ) ) {
37
+ return void callback();
38
+ }
39
+ document.addEventListener( 'DOMContentLoaded', callback, { once: true } );
40
+ }
41
+
42
+ /**
43
+ * Whether DOMContentLoaded has fired, according to navigation timing.
44
+ *
45
+ * Without a navigation entry there is nothing to consult, and waiting would
46
+ * risk an event that never comes: report it as fired, which degrades to
47
+ * running immediately.
48
+ *
49
+ * @return {boolean} Whether the event has fired.
50
+ */
51
+ function hasDomContentLoadedFired() {
52
+ const [ navigation ] = window.performance?.getEntriesByType?.( 'navigation' ) || [];
53
+ return ! navigation || navigation.domContentLoadedEventStart > 0;
54
+ }
55
+
23
56
  /**
24
57
  * Create a hidden input field.
25
58
  *
@@ -2,12 +2,78 @@
2
2
  * Tests for modal-checkout utils.
3
3
  */
4
4
 
5
- import { getCheckoutData } from './utils';
5
+ import { afterDeferredScripts, getCheckoutData } from './utils';
6
6
 
7
7
  afterEach( () => {
8
8
  document.body.innerHTML = '';
9
9
  } );
10
10
 
11
+ describe( 'afterDeferredScripts()', () => {
12
+ let readyState;
13
+ let navigationEntries;
14
+
15
+ beforeEach( () => {
16
+ readyState = 'complete';
17
+ navigationEntries = [];
18
+ Object.defineProperty( document, 'readyState', { configurable: true, get: () => readyState } );
19
+ Object.defineProperty( window.performance, 'getEntriesByType', {
20
+ configurable: true,
21
+ value: type => ( type === 'navigation' ? navigationEntries : [] ),
22
+ } );
23
+ } );
24
+
25
+ afterEach( () => {
26
+ delete document.readyState;
27
+ delete window.performance.getEntriesByType;
28
+ } );
29
+
30
+ // Images still loading keep readyState at `interactive` after DOMContentLoaded,
31
+ // so a bundle arriving then must not wait for an event that will not repeat.
32
+ // Missing navigation timing leaves nothing to consult, and the trigger
33
+ // degrades to running immediately rather than to never running.
34
+ it.each( [
35
+ [ 'the document is complete', 'complete', [] ],
36
+ [ 'DOMContentLoaded already fired and images keep the document interactive', 'interactive', [ { domContentLoadedEventStart: 850 } ] ],
37
+ [ 'navigation timing is unavailable', 'interactive', [] ],
38
+ ] )( 'runs at once when %s', ( _, state, entries ) => {
39
+ readyState = state;
40
+ navigationEntries = entries;
41
+ const callback = jest.fn();
42
+
43
+ afterDeferredScripts( callback );
44
+ expect( callback ).toHaveBeenCalledTimes( 1 );
45
+ } );
46
+
47
+ it( 'waits for DOMContentLoaded, once, when parsing has ended but the event has not fired', () => {
48
+ // The window an async bundle lands in when it beats the deferred scripts:
49
+ // readyState is already `interactive`, DOMContentLoaded is still pending.
50
+ readyState = 'interactive';
51
+ navigationEntries = [ { domContentLoadedEventStart: 0 } ];
52
+ const callback = jest.fn();
53
+
54
+ afterDeferredScripts( callback );
55
+ expect( callback ).not.toHaveBeenCalled();
56
+
57
+ document.dispatchEvent( new Event( 'DOMContentLoaded' ) );
58
+ document.dispatchEvent( new Event( 'DOMContentLoaded' ) );
59
+ expect( callback ).toHaveBeenCalledTimes( 1 );
60
+ } );
61
+
62
+ it( 'waits for DOMContentLoaded while the document is still loading, without consulting timing', () => {
63
+ // `loading` alone says the event is ahead; the empty navigation timing
64
+ // must not be read as permission to run.
65
+ readyState = 'loading';
66
+ navigationEntries = [];
67
+ const callback = jest.fn();
68
+
69
+ afterDeferredScripts( callback );
70
+ expect( callback ).not.toHaveBeenCalled();
71
+
72
+ document.dispatchEvent( new Event( 'DOMContentLoaded' ) );
73
+ expect( callback ).toHaveBeenCalledTimes( 1 );
74
+ } );
75
+ } );
76
+
11
77
  /**
12
78
  * Build a checkout-button-style form: a `quantity` hidden input (as view.php
13
79
  * emits when a block's default seat count is above 1) plus a `data-checkout`
@@ -3,7 +3,7 @@
3
3
  'name' => 'automattic/newspack-blocks',
4
4
  'pretty_version' => 'dev-main',
5
5
  'version' => 'dev-main',
6
- 'reference' => '8c1da1c0b9e039c24135d8101b433ebe48842e9b',
6
+ 'reference' => 'ce1df4abbe28872d98ac84032eabca65ae1b6abf',
7
7
  'type' => 'wordpress-plugin',
8
8
  'install_path' => __DIR__ . '/../../',
9
9
  'aliases' => array(),
@@ -13,7 +13,7 @@
13
13
  'automattic/newspack-blocks' => array(
14
14
  'pretty_version' => 'dev-main',
15
15
  'version' => 'dev-main',
16
- 'reference' => '8c1da1c0b9e039c24135d8101b433ebe48842e9b',
16
+ 'reference' => 'ce1df4abbe28872d98ac84032eabca65ae1b6abf',
17
17
  'type' => 'wordpress-plugin',
18
18
  'install_path' => __DIR__ . '/../../',
19
19
  'aliases' => array(),