PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / modules / file-field / view.js

view.js in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at jetpack_vendor/automattic/jetpack-forms/src/modules/file-field/view.js

1,415 lines 50.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 import { store, getContext, withScope, getElement, getConfig } from '@wordpress/interactivity';
5 /**
6 * Internal dependencies
7 */
8 import { isEmptyValue } from '../../contact-form/js/validate-helper.js';
9
10 // The file field lives in the shared form store, like every other field module. It keeps its own
11 // namespace only for `wp_interactivity_config()` — the upload endpoint, icon path and per-file
12 // error strings — which is the same split `jetpack/field-phone` uses.
13 const NAMESPACE = 'jetpack/form';
14 const CONFIG_NAMESPACE = 'jetpack/field-file';
15
16 // Long enough for the preview to be rendered and the dropzone hidden before focus moves.
17 const PREVIEW_FOCUS_DELAY_MS = 100;
18
19 /*
20 * How many uploads may be in flight at once, across every file field on the page.
21 *
22 * A batch used to open one XMLHttpRequest per file the moment it was added, so selecting ten
23 * 20MB files pushed 200MB at the network at once — each request starved of bandwidth, each
24 * progress bar crawling, and the whole batch finishing later than if they had been sent in turn.
25 */
26 const MAX_CONCURRENT_UPLOADS = 3;
27
28 /*
29 * How long an upload may make no progress before it is treated as hung, in milliseconds.
30 *
31 * Deliberately measured against progress rather than total elapsed time, which is what
32 * `XMLHttpRequest.timeout` offers. A 20MB file on a slow connection legitimately takes minutes, so
33 * any total-time budget generous enough not to kill it is too generous to catch a stall — while a
34 * request that has died silently, or whose response was swallowed by a proxy, reports no progress
35 * at all. Without this, such a request never reaches readyState 4, never frees its slot, and takes
36 * one of the page's three permanently: three of them stop uploads on every file field on the page,
37 * showing nothing but previews stuck on "Uploading…".
38 */
39 const UPLOAD_STALL_TIMEOUT_MS = 60 * 1000;
40
41 /*
42 * How long to wait for a reply once the whole body has been sent, in milliseconds.
43 *
44 * A separate, longer budget because progress events stop at that point — `xhr.upload` fires
45 * `loadend` and nothing more — so everything the server does after receiving the file counts as
46 * silence. It is not: the endpoint stores the file onward, which for 20MB is real work. Measuring
47 * that against the transfer budget aborts uploads that were about to succeed, and since the server
48 * finishes storing a file whose response nobody reads, the visitor retries and pays for the
49 * transfer twice — on an endpoint that was already slow enough to be the problem.
50 */
51 const UPLOAD_RESPONSE_TIMEOUT_MS = 3 * 60 * 1000;
52
53 /*
54 * How long to wait for an upload token, in milliseconds.
55 *
56 * The slot is claimed before the token is requested, so a token request that hangs holds one of
57 * the page's three for as long as the browser keeps the socket — the same page-wide stall the
58 * upload watchdog exists to prevent, in the one window it cannot see.
59 */
60 const TOKEN_REQUEST_TIMEOUT_MS = 30 * 1000;
61
62 // Uploads waiting for a slot. Entries are `{ clientFileId, fieldId, start }`, where `start` is
63 // bound to the scope of the field that queued it — see `enqueueUpload()`.
64 const uploadQueue = [];
65
66 // Stall watchdogs by client file ID, reset on every progress event. See UPLOAD_STALL_TIMEOUT_MS.
67 const uploadStallTimers = new Map();
68
69 /*
70 * Client file IDs whose upload has started and not yet settled. A Set rather than a counter so that
71 * `finishUpload()` is idempotent: several code paths can report the same upload as finished.
72 */
73 const activeUploadIds = new Set();
74
75 // The field each running upload belongs to, so `pumpUploadQueue()` can share slots between fields.
76 const activeUploadFieldIds = new Map();
77
78 /*
79 * The client file ID of the one preview allowed to take focus, or null.
80 *
81 * `data-wp-init` runs once per rendered preview, so a batch runs the focus callback once per file,
82 * each scheduling its own timer against the same 100ms deadline; whichever fired last won, which
83 * is to say focus landed on an arbitrary preview.
84 *
85 * Naming the file rather than raising a flag matters, because file IDs are unique across the page.
86 * A shared boolean can only express "nothing has claimed focus since it was last set", which is a
87 * different question from "is this the first preview of this batch, in this field" — the two come
88 * apart whenever previews from two batches, or from two file fields, mount in the same render.
89 * Then whichever preview happens to mount first consumes the claim and the file the visitor just
90 * chose is left unfocused. An ID cannot be claimed by the wrong preview, and a second batch simply
91 * supersedes the first, which is what a visitor who just picked another file would expect.
92 */
93 let focusClaimFileId = null;
94
95 let uploadToken = null;
96 let tokenExpiry = null;
97 // Set while a token request is in flight so several files added at once share a single request
98 // instead of each firing its own.
99 let pendingTokenRequest = null;
100
101 /**
102 * Returns the upload token, fetching a new one if it expired or was never fetched.
103 *
104 * The token authorizes uploads for the whole site rather than for one field, so it is cached at
105 * module scope and shared by every file field on the page.
106 *
107 * The cache is written inside the shared promise rather than by each awaiting caller. Writing it
108 * in the continuation would leave a microtask-sized window where `pendingTokenRequest` has already
109 * been cleared but `uploadToken` is not yet set, letting a second caller past both guards and
110 * start a redundant request — and with two in flight the last one to resolve would win, which can
111 * leave the cache holding the older token and the earlier expiry.
112 *
113 * @return {Promise<string|null>} The upload token, or null if one could not be obtained.
114 */
115 const getUploadToken = async () => {
116 // Check if the token exists and is not expired
117 if ( uploadToken && tokenExpiry && Date.now() < tokenExpiry ) {
118 return uploadToken;
119 }
120
121 if ( ! pendingTokenRequest ) {
122 pendingTokenRequest = fetchUploadToken()
123 .then( ( { token, expiresAt } ) => {
124 uploadToken = token;
125 // A non-numeric expiry would make every later `Date.now() < tokenExpiry` false and
126 // silently disable the cache for the rest of the page, so treat it as expired now
127 // and re-fetch next time instead.
128 tokenExpiry = Number.isFinite( expiresAt ) ? expiresAt * 1000 : 0;
129 return token;
130 } )
131 .finally( () => {
132 pendingTokenRequest = null;
133 } );
134 }
135
136 return pendingTokenRequest;
137 };
138
139 /**
140 * Fetches the upload token from the server.
141 *
142 * @return {{ token: string, expiresAt: number }} The upload token and its expiration time.
143 */
144 const fetchUploadToken = async () => {
145 const { endpoint } = getConfig( CONFIG_NAMESPACE );
146
147 const tokenError = {
148 token: null, // Assuming the token is in the `token` field
149 expiresAt: 0,
150 };
151 try {
152 const response = await fetch( `${ endpoint }/token`, {
153 method: 'POST',
154 headers: {
155 'Content-Type': 'application/json',
156 },
157 body: JSON.stringify( { context: 'file-upload' } ),
158 // See TOKEN_REQUEST_TIMEOUT_MS. Guarded because AbortSignal.timeout is relatively recent
159 // and a missing signal only costs the timeout, not correctness.
160 ...( typeof AbortSignal?.timeout === 'function'
161 ? { signal: AbortSignal.timeout( TOKEN_REQUEST_TIMEOUT_MS ) }
162 : {} ),
163 } );
164
165 if ( ! response.ok ) {
166 return tokenError;
167 }
168
169 const data = await response.json();
170 return {
171 token: data.token, // Assuming the token is in the `token` field
172 expiresAt: data.expiration,
173 };
174 } catch ( error ) {
175 if ( error ) {
176 return tokenError;
177 }
178 }
179 return tokenError;
180 };
181
182 /**
183 * Format the file size to a human-readable string.
184 *
185 * @param {number} size - The size of the file in bytes.
186 * @param {number} [decimals=2] - The number of decimals to include.
187 *
188 * @return {string} The formatted file size.
189 */
190 const formatBytes = ( size, decimals = 2 ) => {
191 const config = getConfig( CONFIG_NAMESPACE );
192 if ( size === 0 ) return config.i18n.zeroBytes;
193 const k = 1024;
194 const dm = decimals < 0 ? 0 : decimals;
195 const sizes = config.i18n.fileSizeUnits || [ 'Bytes', 'KB', 'MB', 'GB', 'TB' ];
196 const i = Math.floor( Math.log( size ) / Math.log( k ) );
197 const formattedSize = parseFloat( ( size / Math.pow( k, i ) ).toFixed( dm ) );
198 const numberFormat = new Intl.NumberFormat( config.i18n.locale, {
199 minimumFractionDigits: dm,
200 maximumFractionDigits: dm,
201 } );
202 return `${ numberFormat.format( formattedSize ) } ${ sizes[ i ] }`;
203 };
204
205 const getFileIcon = file => {
206 const config = getConfig( CONFIG_NAMESPACE );
207 const fileType = file.type.split( '/' )[ 0 ];
208 const fileExtension = file.name.split( '.' ).pop().toLowerCase();
209
210 const iconMap = {
211 image: 'png',
212 video: 'mp4',
213 audio: 'mp3',
214 document: 'pdf',
215 application: 'txt',
216 };
217
218 const extensionMap = {
219 pdf: 'pdf',
220 doc: 'doc',
221 docx: 'doc',
222 txt: 'txt',
223 ppt: 'ppt',
224 pptx: 'ppt',
225 xls: 'xls',
226 xlsx: 'xls',
227 csv: 'xls',
228 zip: 'zip',
229 sql: 'sql',
230 cal: 'cal',
231 };
232 const iconName = extensionMap[ fileExtension ] || iconMap[ fileType ] || 'txt';
233 return 'url(' + config.iconsPath + iconName + '.svg)';
234 };
235
236 /**
237 * Reads the file field's configuration from the standard `fieldExtra` context entry, the same
238 * place the date field finds its format and the number field its min/max.
239 *
240 * @return {{ maxFiles: number, allowedMimeTypes: string[] }} The field's configuration.
241 */
242 const getFileFieldExtra = () => {
243 const { maxFiles, allowedMimeTypes } = getContext().fieldExtra || {};
244 return {
245 maxFiles: maxFiles ?? 1,
246 allowedMimeTypes: allowedMimeTypes ?? [],
247 };
248 };
249
250 /**
251 * Whether this field's markup has somewhere to show a field-level notice.
252 *
253 * Only true for markup rendered by the current PHP. Runs inside an action's scope, so `getElement()`
254 * resolves to the element the visitor interacted with — the file input or the container, both of
255 * which sit inside `.jetpack-form-file-field__container`.
256 *
257 * @return {boolean} True when the notice element is present.
258 */
259 const hasNoticeElement = () => {
260 const { ref } = getElement();
261
262 return !! ref
263 ?.closest?.( '.jetpack-form-file-field__container' )
264 ?.querySelector( '.jetpack-form-file-field__notice' );
265 };
266
267 /**
268 * Whether the dropzone is currently shown.
269 *
270 * Tests the one thing that actually hides it — `is-hidden`, bound to `state.isFileFieldFull` — in
271 * preference to a general visibility probe. `checkVisibility()` and `offsetParent` both require
272 * layout, which means they answer differently under a test renderer than in a browser, and this
273 * question has a definite answer that does not need layout to find.
274 *
275 * @param {HTMLElement} dropzoneInner - The dropzone's inner button element.
276 *
277 * @return {boolean} True when the dropzone is visible.
278 */
279 const isDropzoneVisible = dropzoneInner =>
280 ! dropzoneInner
281 .closest( '.jetpack-form-file-field__dropzone' )
282 ?.classList.contains( 'is-hidden' );
283
284 /**
285 * The files currently held by the field.
286 *
287 * @return {Array} The field's files.
288 */
289 const getFileFieldFiles = () => getContext().files ?? [];
290
291 /**
292 * Why this file cannot be uploaded, if it cannot be.
293 *
294 * Deliberately excludes the max-file check: whether there is room for a file is a property of the
295 * batch being added rather than of the file itself, and `addFiles()` owns it.
296 *
297 * The type check comes first so that it still wins for a file that is both oversized and of a
298 * disallowed type. That is the precedence the two sequential assignments this replaced happened to
299 * produce — the type check ran second and overwrote the size message — and it is the better of the
300 * two to report, since no smaller version of that file would be accepted either.
301 *
302 * @param {File} file - The file to check.
303 *
304 * @return {string|null} The error message, or null when the file is acceptable.
305 */
306 const getFileError = file => {
307 const config = getConfig( CONFIG_NAMESPACE );
308 const { allowedMimeTypes } = getFileFieldExtra();
309
310 if ( ! allowedMimeTypes.includes( file.type ) ) {
311 return config.i18n.invalidType;
312 }
313
314 if ( file.size > config.maxUploadSize ) {
315 return config.i18n.fileTooLarge;
316 }
317
318 return null;
319 };
320
321 /**
322 * How many more files the field will accept.
323 *
324 * Counts every entry, including those that failed their own type or size check.
325 *
326 * Excluding errored entries reads as kinder — a rejected file was never uploaded, so why should it
327 * hold a place? — but it leaves nothing bounding them. They would neither consume capacity nor
328 * hide the dropzone, so picking a disallowed file over and over would pile up previews without
329 * limit, and `validators.file` reports `invalid_file_has_errors` for every one of them: the same
330 * unsubmittable form this batch work exists to prevent, reached by a different route. A visitor
331 * who needs to replace a rejected file dismisses it with its own × button, which is one click and
332 * was already the established behaviour.
333 *
334 * @return {number} The number of files that can still be added.
335 */
336 const getRemainingCapacity = () => {
337 const { maxFiles } = getFileFieldExtra();
338
339 return Math.max( maxFiles - getFileFieldFiles().length, 0 );
340 };
341
342 /**
343 * Drop the oldest entry that failed, to make room for a file that did not.
344 *
345 * Protects the stand-in entry that markup without a notice element gets instead of a message, but
346 * only while the field is over its limit. That entry is added past the limit by construction, so
347 * displacing it then would hand back a slot the field never had, and since a fresh stand-in is
348 * added whenever a batch is declined, every drop would net one more entry.
349 *
350 * Once the entries fit again — the visitor removed the real file — the stand-in is an ordinary
351 * occupant and may be replaced. Protecting it there instead leaves the field wedged: it holds the
352 * only slot, cannot be evicted, and no further stand-in is added because one is already present,
353 * so every later drop does nothing whatsoever and the visitor is left looking at a preview named
354 * after a file that was never accepted.
355 *
356 * @param {string} noticeMessage - The message the stand-in entry carries.
357 *
358 * @return {boolean} True when an entry was removed.
359 */
360 const evictFailedFile = noticeMessage => {
361 const context = getContext();
362 const { maxFiles } = getFileFieldExtra();
363 const isOverCapacity = context.files.length > maxFiles;
364
365 const index = context.files.findIndex(
366 fileInfo => fileInfo.error && ! ( isOverCapacity && fileInfo.error === noticeMessage )
367 );
368
369 if ( index === -1 ) {
370 return false;
371 }
372
373 releaseFile( context.files[ index ] );
374 context.files.splice( index, 1 );
375
376 return true;
377 };
378
379 /**
380 * Add one file to the context and queue its upload.
381 *
382 * Does not touch the field's value or the notice: `addFiles()` reports the whole batch once, so
383 * that a ten-file drop runs one validation pass rather than ten.
384 *
385 * @param {File} file - The file to add.
386 * @param {string|null} error - Why the file cannot be uploaded, or null.
387 *
388 * @return {string} The client file ID of the entry that was added.
389 */
390 const addFileToContext = ( file, error ) => {
391 const context = getContext();
392
393 const clientFileId = performance.now() + '-' + Math.random();
394 const hasImage =
395 [ 'image/gif', 'image/jpg', 'image/png', 'image/jpeg' ].includes( file.type ) &&
396 URL.createObjectURL;
397 const fileUrl = hasImage ? 'url(' + URL.createObjectURL( file ) + ')' : getFileIcon( file );
398 context.files.push( {
399 name: file.name,
400 formattedSize: formatBytes( file.size, 2 ),
401 hasIcon: ! hasImage,
402 isUploaded: false,
403 hasError: !! error,
404 id: clientFileId,
405 url: hasImage ? fileUrl : null,
406 mask: ! hasImage ? fileUrl : null,
407 error,
408 } );
409
410 // Start the upload if we don't have any errors.
411 ! error && enqueueUpload( file, clientFileId );
412
413 return clientFileId;
414 };
415
416 /**
417 * Add a batch of files to the field.
418 *
419 * Every entry point hands its files here rather than adding them one at a time, because the
420 * max-file limit can only be applied to a batch as a whole. Adding one at a time meant each file
421 * past the limit got its own preview carrying the "too many files" message — and since
422 * `validators.file` reports `invalid_file_has_errors` for any errored entry, dropping ten files on
423 * a field that accepts one produced nine previews that all had to be dismissed by hand before the
424 * form could be submitted. Overflow is now declined outright and reported once.
425 *
426 * Files that fail their own validation are still added, with their own message on their own
427 * preview: those are about the file, and the visitor has to see which one to replace.
428 *
429 * @param {File[]} files - The files to add.
430 *
431 * @return {number} How many files were declined for want of room.
432 */
433 const addFiles = files => {
434 const context = getContext();
435 const { i18n } = getConfig( CONFIG_NAMESPACE );
436
437 let remainingCapacity = getRemainingCapacity();
438 let firstDeclined = null;
439 let declinedCount = 0;
440 let firstAddedId = null;
441
442 for ( const file of files ) {
443 if ( ! file ) {
444 continue;
445 }
446
447 const error = getFileError( file );
448
449 /*
450 * Capacity is spent before the file is examined, so a file rejected for its type or size
451 * occupies a place like any other. It has to: an entry that consumed nothing could be added
452 * again and again, and each one blocks submission through `validators.file`.
453 */
454 if ( remainingCapacity === 0 ) {
455 /*
456 * Except that a file the field can actually take may replace one it could not. Without
457 * this, a single-file field holding one rejected file is "full", so the replacement the
458 * visitor is being asked for is refused with "too many files" — next to a single visible
459 * file. Only a usable file earns the eviction; one bad file cannot displace another.
460 */
461 if ( error || ! evictFailedFile( i18n.maxFiles ) ) {
462 declinedCount++;
463 firstDeclined = firstDeclined ?? file;
464 continue;
465 }
466
467 remainingCapacity++;
468 }
469
470 remainingCapacity--;
471
472 const addedId = addFileToContext( file, error );
473
474 if ( firstAddedId === null ) {
475 firstAddedId = addedId;
476 }
477 }
478
479 // Nominate this batch's first file, by id, as the one preview that may take focus.
480 focusClaimFileId = firstAddedId;
481
482 if ( ! declinedCount ) {
483 context.fileNotice = '';
484 } else if ( hasNoticeElement() ) {
485 context.fileNotice = i18n.maxFiles;
486 } else if ( ! context.files.some( fileInfo => fileInfo.error === i18n.maxFiles ) ) {
487 /*
488 * Markup cached before the notice element existed, served against this bundle — the window
489 * the back-compat shim at the bottom of this file covers. There is nowhere to render
490 * `fileNotice`, so declining silently would make the visitor's files simply vanish. Fall
491 * back to what that markup did understand: a preview carrying the message.
492 *
493 * Guarded on one already being present rather than on capacity, because this entry is added
494 * past the limit by definition — the batch was declined for want of room. Without the guard
495 * every further drop appends another, which is the pile-up this work exists to end, reached
496 * through the back-compat door.
497 */
498 addFileToContext( firstDeclined, i18n.maxFiles );
499 }
500
501 actions.updateField( context.fieldId, context.files );
502
503 return declinedCount;
504 };
505
506 // Map to store AbortControllers for each file upload
507 const uploadControllers = new Map();
508
509 /**
510 * Queue a file's upload, to start as soon as there is a free slot.
511 *
512 * The starter is wrapped with `withScope()` here rather than being called plainly at pump time.
513 * The pump runs from whichever upload settled last, which may belong to a different file field on
514 * the page; without capturing this field's scope now, `uploadFile()` would resolve `getContext()`
515 * against that other field and look for the file in the wrong list.
516 *
517 * @param {File} file - The file to upload.
518 * @param {string} clientFileId - The client file ID.
519 */
520 const enqueueUpload = ( file, clientFileId ) => {
521 uploadQueue.push( {
522 clientFileId,
523 fieldId: getContext().fieldId,
524 start: withScope( () => actions.uploadFile( file, clientFileId ) ),
525 } );
526
527 pumpUploadQueue();
528 };
529
530 /**
531 * Start queued uploads until the concurrency limit is reached.
532 *
533 * Picks the waiting file whose field has the fewest uploads running, rather than simply the one
534 * that has waited longest. Strict arrival order is fine while a field accepts a single file, but
535 * once it accepts several, a visitor who fills the first field before reaching the second would
536 * leave the second field's file waiting behind the whole of the first field's batch — and a
537 * preview stuck at 0% is indistinguishable from a broken field, since nothing on screen says it is
538 * waiting on another field's traffic. Ties keep arrival order, so a single field still uploads in
539 * the order its files were added.
540 */
541 const pumpUploadQueue = () => {
542 while ( activeUploadIds.size < MAX_CONCURRENT_UPLOADS && uploadQueue.length ) {
543 const activePerField = new Map();
544
545 for ( const fieldId of activeUploadFieldIds.values() ) {
546 activePerField.set( fieldId, ( activePerField.get( fieldId ) ?? 0 ) + 1 );
547 }
548
549 let index = 0;
550
551 for ( let candidate = 1; candidate < uploadQueue.length; candidate++ ) {
552 const best = activePerField.get( uploadQueue[ index ].fieldId ) ?? 0;
553 const here = activePerField.get( uploadQueue[ candidate ].fieldId ) ?? 0;
554
555 if ( here < best ) {
556 index = candidate;
557 }
558 }
559
560 const { clientFileId, fieldId, start } = uploadQueue.splice( index, 1 )[ 0 ];
561
562 activeUploadIds.add( clientFileId );
563 activeUploadFieldIds.set( clientFileId, fieldId );
564 start();
565 }
566 };
567
568 /**
569 * Report an upload as settled and let the next one start.
570 *
571 * Idempotent: an upload can be reported as finished both by its own `readystatechange` handler and
572 * by a removal racing it, and freeing the same slot twice would let the queue run over the limit.
573 *
574 * @param {string} clientFileId - The client file ID.
575 */
576 const finishUpload = clientFileId => {
577 clearStallWatchdog( clientFileId );
578 activeUploadFieldIds.delete( clientFileId );
579
580 if ( ! activeUploadIds.delete( clientFileId ) ) {
581 return;
582 }
583
584 pumpUploadQueue();
585 };
586
587 /**
588 * (Re)start the stall watchdog for an upload. See UPLOAD_STALL_TIMEOUT_MS.
589 *
590 * Aborting on expiry rather than just freeing the slot is deliberate: the abort drives the request
591 * to readyState 4 with status 0, which is already handled as a failed upload, so the visitor gets
592 * an error they can act on instead of a preview that sits at its last percentage forever.
593 *
594 * @param {string} clientFileId - The client file ID.
595 * @param {number} [timeout] - How long to wait, defaulting to the transfer budget.
596 */
597 const startStallWatchdog = ( clientFileId, timeout = UPLOAD_STALL_TIMEOUT_MS ) => {
598 clearStallWatchdog( clientFileId );
599
600 uploadStallTimers.set(
601 clientFileId,
602 setTimeout( () => {
603 uploadStallTimers.delete( clientFileId );
604 uploadControllers.get( clientFileId )?.abort();
605 // The abort settles the request, but free the slot here too in case it does not.
606 finishUpload( clientFileId );
607 }, timeout )
608 );
609 };
610
611 /**
612 * Stop watching an upload for a stall.
613 *
614 * @param {string} clientFileId - The client file ID.
615 */
616 const clearStallWatchdog = clientFileId => {
617 const timer = uploadStallTimers.get( clientFileId );
618
619 if ( timer !== undefined ) {
620 clearTimeout( timer );
621 uploadStallTimers.delete( clientFileId );
622 }
623 };
624
625 /**
626 * Read an upload response body, tolerating one that is not JSON.
627 *
628 * @param {string} responseText - The raw response body.
629 *
630 * @return {object|null} The parsed body, or null when it could not be read.
631 */
632 const parseUploadResponse = responseText => {
633 try {
634 return JSON.parse( responseText );
635 } catch {
636 return null;
637 }
638 };
639
640 /**
641 * Drop a file's upload from the queue, if it has not started yet.
642 *
643 * @param {string} clientFileId - The client file ID.
644 */
645 const dequeueUpload = clientFileId => {
646 const index = uploadQueue.findIndex( entry => entry.clientFileId === clientFileId );
647
648 if ( index !== -1 ) {
649 uploadQueue.splice( index, 1 );
650 }
651 };
652
653 /**
654 * Responsible for updating the progress circle.
655 * Gets called on the progress upload.
656 *
657 * @param {string} clientFileId - The client file ID.
658 * @param {ProgressEvent} event - The progress event object.
659 */
660 const onProgress = ( clientFileId, event ) => {
661 /*
662 * A request that fails mid-body emits a trailing progress event *after* readyState 4, so this
663 * runs for uploads that have already settled. Re-arming there would leave a timer running for
664 * an upload nothing will ever finish.
665 */
666 if ( activeUploadIds.has( clientFileId ) ) {
667 // Evidence the request is alive, so the stall watchdog starts over.
668 startStallWatchdog( clientFileId );
669 }
670
671 const progress = ( event.loaded / event.total ) * 100;
672 // We don't want to show 100% progress, as it's misleading.
673 updateFileContext( { progress: Math.min( progress, 97 ) }, clientFileId );
674 };
675
676 /**
677 * React to the onReadyStateChange event when the endpoint returns.
678 *
679 * @param {string} clientFileId - The file ID.
680 * @param {Event} event - The event object.
681 */
682 const onReadyStateChange = ( clientFileId, event ) => {
683 const xhr = event.target;
684 if ( xhr.readyState === 4 ) {
685 // The request has settled either way, so its controller can never abort anything again.
686 // Dropping it here keeps the map from growing for the lifetime of the page.
687 uploadControllers.delete( clientFileId );
688 finishUpload( clientFileId );
689
690 /*
691 * A proxy, a WAF interstitial or a PHP fatal can all answer 200 with something that is not
692 * JSON. Letting SyntaxError out of an event handler would leave the entry on "Uploading…"
693 * for good, and `validators.file` blocks submission on any file that never finished — so a
694 * body we cannot read is reported as a failed upload, which the visitor can act on.
695 */
696 const response = parseUploadResponse( xhr.responseText );
697
698 if ( response === null ) {
699 const config = getConfig( CONFIG_NAMESPACE );
700 updateFileContext( { error: config.i18n.uploadFailed, hasError: true }, clientFileId );
701 return;
702 }
703
704 if ( xhr.status === 200 ) {
705 if ( response.success ) {
706 updateFileContext(
707 {
708 file_id: response.data.file_id,
709 isUploaded: true,
710 name: response.data.name,
711 type: response.data.type,
712 size: response.data.size,
713 fileJson: JSON.stringify( {
714 file_id: response.data.file_id,
715 name: response.data.name,
716 size: response.data.size,
717 type: response.data.type,
718 } ),
719 },
720 clientFileId
721 );
722 return;
723 }
724 } else {
725 const config = getConfig( CONFIG_NAMESPACE );
726 updateFileContext( { error: config.i18n.uploadFailed, hasError: true }, clientFileId );
727 return;
728 }
729 if ( xhr.responseText ) {
730 updateFileContext( { error: response.message, hasError: true }, clientFileId );
731 }
732 }
733 };
734
735 /**
736 * Update the context with the new updatedFile object based on the file ID.
737 *
738 * XHR events can outlive the entry they describe — `resetFiles` empties the list without waiting
739 * for anything in flight — so a missing file is an expected outcome here, not an error. Without
740 * the guard, `findIndex` returns -1 and `Object.assign( undefined, … )` throws out of a progress
741 * handler and takes the rest of the upload's reporting with it.
742 *
743 * @param {object} updatedFile - The updated file object.
744 * @param {string} clientFileId - The client file ID.
745 */
746 const updateFileContext = ( updatedFile, clientFileId ) => {
747 const context = getContext();
748 const index = context.files.findIndex( file => file.id === clientFileId );
749
750 if ( index === -1 ) {
751 return;
752 }
753
754 context.files[ index ] = Object.assign( context.files[ index ], updatedFile );
755
756 actions.updateField( context.fieldId, context.files );
757 };
758
759 /**
760 * Ask the server to drop an already-uploaded file.
761 *
762 * Deliberately not awaited by the caller: the field's own state is updated first so the UI never
763 * waits on the network. Safe to run after the interactivity scope is gone, since `getConfig()`
764 * with an explicit namespace reads the global config map rather than the current scope.
765 *
766 * @param {string} fileId - The server-side file ID.
767 */
768 const deleteUploadedFile = async fileId => {
769 const token = await getUploadToken();
770
771 if ( ! token ) {
772 return;
773 }
774
775 const { endpoint } = getConfig( CONFIG_NAMESPACE );
776 const formData = new FormData();
777 formData.append( 'token', token );
778 formData.append( 'file_id', fileId );
779
780 try {
781 await fetch( `${ endpoint }/remove`, { method: 'POST', body: formData } );
782 } catch {
783 // The file is already gone from the field either way; a failed cleanup call is not
784 // something the visitor can act on.
785 }
786 };
787
788 /**
789 * Release everything attached to a file: its in-flight upload and its preview object URL.
790 *
791 * Shared by `removeFile` and `resetFiles` so the two cannot drift — a reset that skipped this
792 * left the XHR running against a file nothing referenced any more, pinned the blob URL for the
793 * life of the page, and leaked the controller into `uploadControllers`.
794 *
795 * @param {object} file - The file entry to release.
796 */
797 const releaseFile = file => {
798 if ( ! file ) {
799 return;
800 }
801
802 /*
803 * The file may be waiting for a slot rather than uploading, in which case there is no
804 * controller to abort and nothing else would ever take it off the queue.
805 */
806 dequeueUpload( file.id );
807 clearStallWatchdog( file.id );
808
809 const abortController = uploadControllers.get( file.id );
810 if ( abortController ) {
811 abortController.abort();
812 uploadControllers.delete( file.id );
813 }
814
815 if ( file.url ) {
816 // Strip the `url(…)` wrapper the style binding needs to get back to the blob URL.
817 URL.revokeObjectURL( file.url.substring( 4, file.url.length - 1 ) );
818 }
819
820 // Freed last, because it starts the next queued upload: doing it before the abort above would
821 // briefly run one request over the limit.
822 finishUpload( file.id );
823 };
824
825 const { state, actions, callbacks } = store( NAMESPACE, {
826 state: {
827 validators: {
828 /**
829 * Validates the file field's value: the array of files held in context.
830 *
831 * Mirrors `validators.phone` — a registered validator owns its own empty/required
832 * handling rather than leaning on the shared `validateField()` helper.
833 *
834 * @param {Array} value - The field's files.
835 * @param {boolean} isRequired - Whether the field is required.
836 * @return {string} The validation result.
837 */
838 file: ( value, isRequired ) => {
839 if ( isEmptyValue( value ) ) {
840 return isRequired ? 'is_required' : 'yes';
841 }
842
843 if ( value.some( file => file.error ) ) {
844 return 'invalid_file_has_errors';
845 }
846
847 if ( value.some( file => ! file.isUploaded ) ) {
848 return 'invalid_file_uploading';
849 }
850
851 return 'yes';
852 },
853 },
854
855 // Both getters tolerate a missing `files`: they live in the shared form store now, so a
856 // stray binding from a non-file field would otherwise throw rather than read as "empty".
857 get hasFileFieldFiles() {
858 return getFileFieldFiles().length > 0;
859 },
860
861 // Hidden exactly when there is no room, on the same count `getRemainingCapacity()` uses.
862 get isFileFieldFull() {
863 return getRemainingCapacity() === 0;
864 },
865
866 // Whether the field-level notice — currently only "too many files" — has something to say.
867 get hasFileFieldNotice() {
868 return !! getContext().fileNotice;
869 },
870 },
871
872 actions: {
873 onFileDropzoneKeyDown: event => {
874 // Holding the key down would otherwise reopen the picker on every auto-repeat.
875 if ( event.repeat ) {
876 return;
877 }
878
879 if ( event.key === 'Enter' || event.key === ' ' ) {
880 event.preventDefault();
881 actions.openFilePicker( event );
882 }
883 },
884
885 /**
886 * Open the file picker dialog.
887 */
888 openFilePicker() {
889 const { ref } = getElement();
890 const fileInput = ref.parentNode.querySelector( '.jetpack-form-file-field' );
891
892 if ( fileInput ) {
893 fileInput.value = ''; // Reset the field so that we always get the onchange event.
894 fileInput.click();
895 }
896 },
897
898 /**
899 * Handle file added event.
900 *
901 * @param {Event} event - The event object.
902 */
903 fileAdded( event ) {
904 addFiles( Array.from( event.target.files ) );
905 },
906
907 /**
908 * Handle file dropped event.
909 *
910 * @param {DragEvent} event - The drag event object.
911 */
912 fileDropped: event => {
913 event.preventDefault();
914 // A drop fires no focus event, so the form's `focusin` handler never sees it.
915 // Without this, dropping a file and submitting would report the fill as starting
916 // at the submit button rather than at the drop.
917 actions.trackFirstInteraction();
918 if ( event.dataTransfer ) {
919 const droppedFiles = [];
920 let skippedDirectory = false;
921
922 for ( const item of Array.from( event.dataTransfer.items ) ) {
923 // Dragging selected text, a link or an image from another tab yields items with
924 // `kind === 'string'`. Those return null from both `webkitGetAsEntry()` and
925 // `getAsFile()`, so they have to be filtered before either is dereferenced.
926 if ( item.kind !== 'file' ) {
927 continue;
928 }
929 // Directories are skipped rather than aborting the whole drop: returning here
930 // discarded every remaining file in a mixed selection and left `isDropping`
931 // set, stranding the dropzone in its drag-hover style.
932 if ( item.webkitGetAsEntry()?.isDirectory ) {
933 skippedDirectory = true;
934 continue;
935 }
936 // `getAsFile()` can still return null for a `file` item, and a batch of nothing but
937 // nulls would otherwise clear an existing notice and re-nominate a focus target.
938 const droppedFile = item.getAsFile();
939
940 if ( droppedFile ) {
941 droppedFiles.push( droppedFile );
942 }
943 }
944
945 /*
946 * A drop carrying nothing this field can take — only directories, or only dragged
947 * text — leaves the files alone rather than reporting an empty batch.
948 */
949 const declinedCount = droppedFiles.length ? addFiles( droppedFiles ) : 0;
950
951 /*
952 * Say so when a folder was dropped and this drop had nothing more pressing to report.
953 * Ignoring it in silence leaves the visitor watching for an upload that will never
954 * start, and any notice still up from an earlier drop would appear to describe the
955 * folder. The string has been in the config since the field shipped, unused.
956 */
957 if ( skippedDirectory && ! declinedCount && hasNoticeElement() ) {
958 getContext().fileNotice = getConfig( CONFIG_NAMESPACE ).i18n.folderNotSupported;
959 }
960 }
961 const context = getContext();
962 context.isDropping = false;
963 },
964
965 /**
966 * Handle drag over event.
967 *
968 * @param {DragEvent} event - The drag event object.
969 */
970 onFileDragOver: event => {
971 const context = getContext();
972 context.isDropping = true;
973 event.preventDefault();
974 },
975
976 /**
977 * Handle drag leave event.
978 */
979 onFileDragLeave: () => {
980 const context = getContext();
981 context.isDropping = false;
982 },
983
984 /**
985 * Make the endpoint request.
986 * This function is a generator so that we can use the withScope function.
987 * And the context gets passed to the onProgress and onReadyStateChange functions.
988 *
989 * @param {File} file - The file to upload.
990 * @param {string} clientFileId - The client file ID.
991 * @yield {Promise<string>} The upload token.
992 */
993 uploadFile: function* ( file, clientFileId ) {
994 const { endpoint, i18n } = getConfig( CONFIG_NAMESPACE );
995
996 /*
997 * The slot was claimed before this generator was started, and until `readystatechange` is
998 * wired up nothing else would ever report this upload as finished. Anything that throws in
999 * between — `xhr.open()` or `xhr.send()`, both of which the XHR spec allows, or a rejection
1000 * out of the token request — would leave the ID in `activeUploadIds` for the life of the
1001 * page, shrinking the page-wide limit by one every time, with no error anywhere.
1002 *
1003 * The token request is inside this on purpose even though `fetchUploadToken()` resolves on
1004 * every branch it currently has. That is a property of today's implementation, not of the
1005 * contract, and it reads `getConfig()` before its own try — the guard should not depend on
1006 * either staying true.
1007 */
1008 try {
1009 const token = yield getUploadToken();
1010
1011 if ( ! token ) {
1012 updateFileContext( { error: i18n.uploadFailed, hasError: true }, clientFileId );
1013 finishUpload( clientFileId );
1014 return;
1015 }
1016
1017 /*
1018 * The token round-trip suspends this generator, and there is no AbortController
1019 * registered yet — so a removal during that window has nothing to cancel and returns as
1020 * though it worked. Without this check the upload would resume and send a file the
1021 * visitor already deleted. Sharing one token request across a batch widens the window,
1022 * since files now queue behind a single fetch rather than each firing their own.
1023 */
1024 const context = getContext();
1025 if ( ! context.files.some( fileObject => fileObject.id === clientFileId ) ) {
1026 finishUpload( clientFileId );
1027 return;
1028 }
1029
1030 const xhr = new XMLHttpRequest();
1031 const formData = new FormData();
1032
1033 // Create an AbortController for this upload
1034 const abortController = new AbortController();
1035 uploadControllers.set( clientFileId, abortController );
1036
1037 xhr.open( 'POST', endpoint, true );
1038 xhr.upload.addEventListener(
1039 'progress',
1040 withScope( onProgress.bind( this, clientFileId ) )
1041 );
1042 // The body is out; everything from here is the server's time. See the two budgets.
1043 xhr.upload.addEventListener( 'loadend', () => {
1044 if ( activeUploadIds.has( clientFileId ) ) {
1045 startStallWatchdog( clientFileId, UPLOAD_RESPONSE_TIMEOUT_MS );
1046 }
1047 } );
1048 xhr.addEventListener(
1049 'readystatechange',
1050 withScope( onReadyStateChange.bind( this, clientFileId ) )
1051 );
1052
1053 // Handle abort signal
1054 abortController.signal.addEventListener( 'abort', () => {
1055 xhr.abort();
1056 } );
1057
1058 formData.append( 'file', file );
1059 formData.append( 'token', token );
1060
1061 // Armed before the send, so a request that dies before its first progress event is
1062 // still caught.
1063 startStallWatchdog( clientFileId );
1064 xhr.send( formData );
1065 } catch {
1066 /*
1067 * Registered before `open()`, and nothing else would remove it: the readystatechange
1068 * listener is only attached once `open()` has returned.
1069 */
1070 uploadControllers.delete( clientFileId );
1071 updateFileContext( { error: i18n.uploadFailed, hasError: true }, clientFileId );
1072 finishUpload( clientFileId );
1073 }
1074 },
1075
1076 /**
1077 * Reset the field, releasing anything the files still hold. See `releaseFile()`.
1078 */
1079 resetFiles: () => {
1080 const context = getContext();
1081 context.files.forEach( releaseFile );
1082 /*
1083 * Empty in place rather than reassigning. The legacy back-compat alias
1084 * (`bridgeLegacyContext`) points the old markup's context at this same array by
1085 * reference, so a reassignment would strand the old `data-wp-each--file` binding on the
1086 * previous array and leave removed files visible.
1087 */
1088 context.files.splice( 0 );
1089 context.fileNotice = '';
1090
1091 /*
1092 * Re-validate, the way `removeFile` does. Without this the field keeps whatever error it
1093 * held before the reset: `releaseFile()` aborts each upload, an abort settles the request
1094 * synchronously as readyState 4 / status 0, and that path records `invalid_file_has_errors`
1095 * against the field on its way out. The files are then gone but the error is not, so the
1096 * form refuses to submit and asks the visitor to remove file errors from a field showing
1097 * no files at all.
1098 */
1099 actions.updateField( context.fieldId, context.files );
1100 },
1101
1102 /**
1103 * Remove a file from the context and cancel its upload if in progress.
1104 *
1105 * The UI update happens first and the server-side delete is fired without blocking it.
1106 * Waiting on a token round-trip before removing the entry left the preview in place and
1107 * the dropzone hidden — `state.isFileFieldFull` still counted the file — so a slow or
1108 * failed network made the field look stuck with no way to add a replacement.
1109 *
1110 * @param {Event} event - The event object.
1111 */
1112 removeFile: event => {
1113 event.preventDefault();
1114
1115 const context = getContext();
1116 const clientFileId = event.target.dataset.id;
1117 const file = context.files.find( fileObject => fileObject.id === clientFileId );
1118
1119 // A second activation while the first is still settling would revoke an already-revoked
1120 // URL and POST a duplicate delete.
1121 if ( ! file ) {
1122 return;
1123 }
1124
1125 releaseFile( file );
1126
1127 /*
1128 * Remove in place rather than reassigning `context.files`. The legacy back-compat alias
1129 * (`bridgeLegacyContext`) shares this array with the old markup's context by reference, so
1130 * a reassignment would leave the old `data-wp-each--file` binding on the stale array,
1131 * keeping a removed file visible. An empty array is a legitimate value here — the
1132 * registered validator turns it into `is_required` or `yes` as appropriate.
1133 */
1134 const index = context.files.indexOf( file );
1135 if ( index !== -1 ) {
1136 context.files.splice( index, 1 );
1137 }
1138
1139 // There is room again, so a "too many files" notice from an earlier batch is now wrong.
1140 context.fileNotice = '';
1141
1142 actions.updateField( context.fieldId, context.files );
1143
1144 if ( file.file_id ) {
1145 deleteUploadedFile( file.file_id );
1146 }
1147 },
1148
1149 removeFileKeydown: event => {
1150 // Holding the key down would otherwise fire a second removal at the repeat rate.
1151 if ( event.repeat ) {
1152 return;
1153 }
1154
1155 if ( event.key === 'Enter' || event.key === ' ' ) {
1156 event.preventDefault();
1157 actions.removeFile( event );
1158 }
1159 },
1160 },
1161
1162 callbacks: {
1163 /**
1164 * Move focus to a newly added file preview.
1165 *
1166 * The preview carries `tabindex="0"` and an `aria-label` of the file name, so it is the
1167 * only meaningful focus target once a file is added — the dropzone is hidden as soon as
1168 * `state.isFileFieldFull` becomes true, and focusing a hidden element strands keyboard users.
1169 *
1170 * The returned function is the effect cleanup: `data-wp-init` resolves the callback without
1171 * invoking it, calls it once, and hands whatever it returns to Preact's `useEffect` as the
1172 * teardown. So this runs when the preview unmounts — that is, when the file is removed —
1173 * and hands focus back to the dropzone, which is visible again by then. Without it, removal
1174 * destroys the focused element and focus falls to `<body>`.
1175 *
1176 * The container is captured while the preview is still attached: by cleanup time the node
1177 * is detached, so `ref.closest()` would return null and querying it would throw.
1178 *
1179 * @return {Function} Cleanup that cancels the pending focus and restores it to the dropzone.
1180 */
1181 focusFilePreview: () => {
1182 const { ref } = getElement();
1183 const container = ref.closest( '.jetpack-form-file-field__container' );
1184
1185 /*
1186 * Only the preview for the file this batch nominated takes focus; see `focusClaimFileId`.
1187 * The rest still return the cleanup below, so removing any of them restores focus normally.
1188 */
1189 /*
1190 * `data-wp-each` puts the item under the namespace of the element that declared it, which
1191 * is the legacy one on cached markup — while this callback resolves `getContext()` against
1192 * `jetpack/form`, because the shim delegates through that store's proxy. Reading only the
1193 * shared context would mean no preview ever claims focus on that markup.
1194 */
1195 const previewFile = getContext().file ?? getContext( CONFIG_NAMESPACE )?.file;
1196
1197 const claimsFocus = focusClaimFileId !== null && previewFile?.id === focusClaimFileId;
1198
1199 if ( claimsFocus ) {
1200 focusClaimFileId = null;
1201 }
1202
1203 // `isConnected` guards the case where the file is removed inside the delay: focusing a
1204 // detached node is a silent no-op that would strand focus on `<body>`.
1205 const focusTimer = claimsFocus
1206 ? setTimeout( () => {
1207 /*
1208 * Only move focus that is not already somewhere deliberate. A file dragged from
1209 * the desktop never moves document focus, so a visitor mid-sentence in the message
1210 * box would otherwise lose their caret — and the next keystrokes — to a preview
1211 * appearing 100ms later. The restore path below already reasons this way.
1212 */
1213 const { activeElement, body } = ref.ownerDocument;
1214 const focusIsIdle =
1215 ! activeElement || activeElement === body || !! container?.contains( activeElement );
1216
1217 if ( ref.isConnected && focusIsIdle ) {
1218 ref.focus( { focusVisible: true } );
1219 }
1220 }, PREVIEW_FOCUS_DELAY_MS )
1221 : null;
1222
1223 return () => {
1224 clearTimeout( focusTimer );
1225
1226 const dropzone = container?.querySelector( '.jetpack-form-file-field__dropzone-inner' );
1227
1228 /*
1229 * Nothing cancels this second timer, so check at fire time that focus is still
1230 * the orphan this callback exists to rescue. Destroying the focused preview drops
1231 * focus to `<body>`; if the visitor has since tabbed or clicked to a real element,
1232 * moving them to the dropzone would be stealing focus rather than restoring it.
1233 */
1234 setTimeout( () => {
1235 if ( ! dropzone?.isConnected ) {
1236 return;
1237 }
1238
1239 const { activeElement, body } = dropzone.ownerDocument;
1240
1241 if ( activeElement && activeElement !== body ) {
1242 return;
1243 }
1244
1245 /*
1246 * `isConnected` is not enough: the dropzone is hidden with `display: none` while the
1247 * field is full, and a hidden element is still connected. Calling focus() on it is a
1248 * silent no-op, which would leave focus on <body> — precisely what this is here to
1249 * prevent. Fall back to whatever preview remains.
1250 */
1251 const target = isDropzoneVisible( dropzone )
1252 ? dropzone
1253 : container?.querySelector( '.jetpack-form-file-field__preview' );
1254
1255 target?.focus( { focusVisible: true } );
1256 }, PREVIEW_FOCUS_DELAY_MS );
1257 };
1258 },
1259 },
1260 } );
1261
1262 /*
1263 * Back-compatibility shim for markup cached before this change. Remove one release after this
1264 * ships, along with the `@deprecated` note in the changelog.
1265 *
1266 * A page cache can serve HTML carrying `data-wp-interactive="jetpack/field-file"` against this
1267 * bundle, and that fails silently: the runtime auto-creates an empty store for an unknown
1268 * namespace rather than erroring, so every directive in the container becomes a no-op with no
1269 * console output outside SCRIPT_DEBUG. On a *required* file field it is unrecoverable — the
1270 * wrapper is still `jetpack/form`, so `registerField` records `is_required`, no file can ever be
1271 * added to clear it, and the submit gate blocks the form permanently.
1272 *
1273 * (The mirror case, new markup against a stale bundle, is handled by the content-hash suffix in
1274 * `enqueue_file_field_assets()` rather than here.)
1275 */
1276 /**
1277 * Point the shared context at the legacy one so the new implementation can run against old markup.
1278 *
1279 * Old markup declared `files` under `jetpack/field-file` and carried `maxFiles`/`allowedMimeTypes`
1280 * as loose context keys rather than in `fieldExtra`. Assigning the same array reference rather than
1281 * a copy matters: the old template's `data-wp-each--file` still binds to the legacy context, so
1282 * both bindings have to observe the same underlying object to stay in step.
1283 */
1284 const bridgeLegacyContext = () => {
1285 const legacy = getContext( CONFIG_NAMESPACE );
1286 const shared = getContext( NAMESPACE );
1287
1288 if ( ! legacy || ! shared ) {
1289 return;
1290 }
1291
1292 if ( shared.files === undefined ) {
1293 shared.files = legacy.files;
1294 }
1295
1296 /*
1297 * Test for the key rather than for a truthy value or for `fieldExtra` itself.
1298 *
1299 * `! shared.fieldExtra` never fires: the wrapper carrying old markup is the unchanged PHP of
1300 * the previous release, which emitted `fieldExtra` for a file field as `get_field_extra()`'s
1301 * untouched `$extra_attrs` — an empty array, which `wp_json_encode()` writes as `[]` and JS
1302 * reads as truthy. The allowlist then stays empty and every file is rejected as a disallowed
1303 * type.
1304 *
1305 * Testing the value (`! shared.fieldExtra?.allowedMimeTypes`) fixes that but is not idempotent:
1306 * this runs inside the `hasFiles`/`hasMaxFiles` getters, which the runtime evaluates in a
1307 * computed, and a legacy context with no usable list would assign a fresh object on every
1308 * evaluation, invalidating the computed that just read it and spinning the main thread. Keying
1309 * on the property means the second call is always a no-op.
1310 */
1311 if ( ! shared.fieldExtra || ! ( 'allowedMimeTypes' in shared.fieldExtra ) ) {
1312 shared.fieldExtra = {
1313 maxFiles: legacy.maxFiles,
1314 allowedMimeTypes: legacy.allowedMimeTypes,
1315 };
1316 }
1317 };
1318
1319 /*
1320 * Look the delegate up by name at call time rather than closing over it.
1321 *
1322 * `actions` and `callbacks` are store proxies, and the proxy's `get` trap is what binds a
1323 * function to a scope: it returns `withScope( fn )`, and `withScope()` captures `getScope()` at
1324 * the moment of the property read. Reading `actions.removeFile` while this module is still
1325 * evaluating captures an empty scope stack, so every later call would run `setScope( undefined )`
1326 * and the first `getContext()` inside would throw on `scope.context`. Deferring the read to call
1327 * time means the proxy binds the scope the runtime has already pushed for the directive.
1328 */
1329 const withLegacyContext =
1330 actionName =>
1331 ( ...args ) => {
1332 bridgeLegacyContext();
1333 return actions[ actionName ]( ...args );
1334 };
1335
1336 /*
1337 * The names above are strings, so renaming a shared action would not update them and nothing would
1338 * notice until a visitor on stale cached HTML hit a `TypeError`. Reading each one here (without
1339 * calling it) turns that into a console warning on every page load carrying the field. Reading is
1340 * safe: the proxy's `get` trap only binds a scope for the caller it returns, and this discards it.
1341 */
1342 const assertLegacyDelegatesExist = names => {
1343 if ( ! globalThis.SCRIPT_DEBUG ) {
1344 return;
1345 }
1346
1347 const missing = names.filter( name => typeof actions[ name ] !== 'function' );
1348
1349 if ( missing.length ) {
1350 // eslint-disable-next-line no-console
1351 console.warn(
1352 `jetpack/field-file back-compat shim: no such action(s) on jetpack/form: ${ missing.join(
1353 ', '
1354 ) }`
1355 );
1356 }
1357 };
1358
1359 store( CONFIG_NAMESPACE, {
1360 state: {
1361 get hasFiles() {
1362 bridgeLegacyContext();
1363 return state.hasFileFieldFiles;
1364 },
1365 get hasMaxFiles() {
1366 bridgeLegacyContext();
1367 return state.isFileFieldFull;
1368 },
1369 },
1370 actions: {
1371 handleKeyDown: withLegacyContext( 'onFileDropzoneKeyDown' ),
1372 openFilePicker: withLegacyContext( 'openFilePicker' ),
1373 fileAdded: withLegacyContext( 'fileAdded' ),
1374 /*
1375 * Delegating is not enough here. The shared implementation ends by clearing `isDropping`
1376 * on the shared context, but the old template binds `is-dropping` to the legacy one — the
1377 * same reason `dragOver`/`dragLeave` below stay local. Without this the dropzone keeps its
1378 * drag-hover highlight after a drop and only sheds it on the next `dragleave`.
1379 */
1380 fileDropped: ( ...args ) => {
1381 bridgeLegacyContext();
1382 const result = actions.fileDropped( ...args );
1383 getContext( CONFIG_NAMESPACE ).isDropping = false;
1384 return result;
1385 },
1386 removeFile: withLegacyContext( 'removeFile' ),
1387 removeFileKeydown: withLegacyContext( 'removeFileKeydown' ),
1388 resetFiles: withLegacyContext( 'resetFiles' ),
1389
1390 // The old template binds `is-dropping` to the legacy context, so these write there directly
1391 // rather than delegating to handlers that would set the flag on the shared context.
1392 dragOver: event => {
1393 getContext( CONFIG_NAMESPACE ).isDropping = true;
1394 event.preventDefault();
1395 },
1396 dragLeave: () => {
1397 getContext( CONFIG_NAMESPACE ).isDropping = false;
1398 },
1399 },
1400 callbacks: {
1401 // Read through the proxy at call time, for the reason given on `withLegacyContext()`.
1402 focusElement: ( ...args ) => callbacks.focusFilePreview( ...args ),
1403 },
1404 } );
1405
1406 assertLegacyDelegatesExist( [
1407 'onFileDropzoneKeyDown',
1408 'openFilePicker',
1409 'fileAdded',
1410 'fileDropped',
1411 'removeFile',
1412 'removeFileKeydown',
1413 'resetFiles',
1414 ] );
1415