| 1 | /* eslint-disable dot-notation */ |
| 2 | |
| 3 | // Shared implementation and constants between the inline script and external |
| 4 | // runtime instruction sets. |
| 5 | |
| 6 | const ELEMENT_NODE = 1; |
| 7 | const COMMENT_NODE = 8; |
| 8 | const ACTIVITY_START_DATA = '&'; |
| 9 | const ACTIVITY_END_DATA = '/&'; |
| 10 | const SUSPENSE_START_DATA = '$'; |
| 11 | const SUSPENSE_END_DATA = '/$'; |
| 12 | const SUSPENSE_PENDING_START_DATA = '$?'; |
| 13 | const SUSPENSE_QUEUED_START_DATA = '$~'; |
| 14 | const SUSPENSE_FALLBACK_START_DATA = '$!'; |
| 15 | |
| 16 | const FALLBACK_THROTTLE_MS = 300; |
| 17 | |
| 18 | const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500; |
| 19 | |
| 20 | // If you have a target goal in mind for a metric to hit, you don't want the |
| 21 | // only reason you miss it by a little bit to be throttling heuristics. |
| 22 | // This tries to avoid throttling if avoiding it would let you hit this metric. |
| 23 | // This is derived from trying to hit an LCP of 2.5 seconds with some head room. |
| 24 | const TARGET_VANITY_METRIC = 2300; |
| 25 | |
| 26 | // TODO: Symbols that are referenced outside this module use dynamic accessor |
| 27 | // notation instead of dot notation to prevent Closure's advanced compilation |
| 28 | // mode from renaming. We could use extern files instead, but I couldn't get it |
| 29 | // working. Closure converts it to a dot access anyway, though, so it's not an |
| 30 | // urgent issue. |
| 31 | |
| 32 | export function revealCompletedBoundaries(batch) { |
| 33 | window['$RT'] = performance.now(); |
| 34 | for (let i = 0; i < batch.length; i += 2) { |
| 35 | const suspenseIdNode = batch[i]; |
| 36 | const contentNode = batch[i + 1]; |
| 37 | if (contentNode.parentNode === null) { |
| 38 | // If the client has failed hydration we may have already deleted the streaming |
| 39 | // segments. The server may also have emitted a complete instruction but cancelled |
| 40 | // the segment. Regardless we can ignore this case. |
| 41 | } else { |
| 42 | // We can detach the content now. |
| 43 | // Completions of boundaries within this contentNode will now find the boundary |
| 44 | // in its designated place. |
| 45 | contentNode.parentNode.removeChild(contentNode); |
| 46 | } |
| 47 | // Clear all the existing children. This is complicated because |
| 48 | // there can be embedded Suspense boundaries in the fallback. |
| 49 | // This is similar to clearSuspenseBoundary in ReactFiberConfigDOM. |
| 50 | // TODO: We could avoid this if we never emitted suspense boundaries in fallback trees. |
| 51 | // They never hydrate anyway. However, currently we support incrementally loading the fallback. |
| 52 | const parentInstance = suspenseIdNode.parentNode; |
| 53 | if (!parentInstance) { |
| 54 | // We may have client-rendered this boundary already. Skip it. |
| 55 | continue; |
| 56 | } |
| 57 | |
| 58 | // Find the boundary around the fallback. This is always the previous node. |
| 59 | const suspenseNode = suspenseIdNode.previousSibling; |
| 60 | |
| 61 | let node = suspenseIdNode; |
| 62 | let depth = 0; |
| 63 | do { |
| 64 | if (node && node.nodeType === COMMENT_NODE) { |
| 65 | const data = node.data; |
| 66 | if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { |
| 67 | if (depth === 0) { |
| 68 | break; |
| 69 | } else { |
| 70 | depth--; |
| 71 | } |
| 72 | } else if ( |
| 73 | data === SUSPENSE_START_DATA || |
| 74 | data === SUSPENSE_PENDING_START_DATA || |
| 75 | data === SUSPENSE_QUEUED_START_DATA || |
| 76 | data === SUSPENSE_FALLBACK_START_DATA || |
| 77 | data === ACTIVITY_START_DATA |
| 78 | ) { |
| 79 | depth++; |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | const nextNode = node.nextSibling; |
| 84 | parentInstance.removeChild(node); |
| 85 | node = nextNode; |
| 86 | } while (node); |
| 87 | |
| 88 | const endOfBoundary = node; |
| 89 | |
| 90 | // Insert all the children from the contentNode between the start and end of suspense boundary. |
| 91 | while (contentNode.firstChild) { |
| 92 | parentInstance.insertBefore(contentNode.firstChild, endOfBoundary); |
| 93 | } |
| 94 | |
| 95 | suspenseNode.data = SUSPENSE_START_DATA; |
| 96 | if (suspenseNode['_reactRetry']) { |
| 97 | requestAnimationFrame(suspenseNode['_reactRetry']); |
| 98 | } |
| 99 | } |
| 100 | batch.length = 0; |
| 101 | } |
| 102 | |
| 103 | export function revealCompletedBoundariesWithViewTransitions( |
| 104 | revealBoundaries, |
| 105 | batch, |
| 106 | ) { |
| 107 | let shouldStartViewTransition = false; |
| 108 | let autoNameIdx = 0; |
| 109 | const restoreQueue = []; |
| 110 | function applyViewTransitionName(element, classAttributeName) { |
| 111 | const className = element.getAttribute(classAttributeName); |
| 112 | if (!className) { |
| 113 | return; |
| 114 | } |
| 115 | // Add any elements we apply a name to a queue to be reverted when we start. |
| 116 | const elementStyle = element.style; |
| 117 | restoreQueue.push( |
| 118 | element, |
| 119 | elementStyle['viewTransitionName'], |
| 120 | elementStyle['viewTransitionClass'], |
| 121 | ); |
| 122 | if (className !== 'auto') { |
| 123 | elementStyle['viewTransitionClass'] = className; |
| 124 | } |
| 125 | let name = element.getAttribute('vt-name'); |
| 126 | if (!name) { |
| 127 | // Auto-generate a name for this one. |
| 128 | // TODO: We don't have a prefix to pick from here but maybe we don't need it |
| 129 | // since it's only applicable temporarily during this specific animation. |
| 130 | const idPrefix = ''; |
| 131 | name = '_' + idPrefix + 'T_' + autoNameIdx++ + '_'; |
| 132 | } |
| 133 | // If the name isn't valid CSS identifier, base64 encode the name instead. |
| 134 | // This doesn't let you select it in custom CSS selectors but it does work in current |
| 135 | // browsers. |
| 136 | const escapedName = |
| 137 | CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name; |
| 138 | elementStyle['viewTransitionName'] = escapedName; |
| 139 | shouldStartViewTransition = true; |
| 140 | } |
| 141 | try { |
| 142 | const existingTransition = document['__reactViewTransition']; |
| 143 | if (existingTransition) { |
| 144 | // Retry after the previous ViewTransition finishes. |
| 145 | existingTransition.finished.finally(window['$RV'].bind(null, batch)); |
| 146 | return; |
| 147 | } |
| 148 | // First collect all entering names that might form pairs exiting names. |
| 149 | const appearingViewTransitions = new Map(); |
| 150 | for (let i = 1; i < batch.length; i += 2) { |
| 151 | const contentNode = batch[i]; |
| 152 | const appearingElements = contentNode.querySelectorAll('[vt-share]'); |
| 153 | for (let j = 0; j < appearingElements.length; j++) { |
| 154 | const appearingElement = appearingElements[j]; |
| 155 | appearingViewTransitions.set( |
| 156 | appearingElement.getAttribute('vt-name'), |
| 157 | appearingElement, |
| 158 | ); |
| 159 | } |
| 160 | } |
| 161 | const suspenseyImages = []; |
| 162 | // Next we'll find the nodes that we're going to animate and apply names to them.. |
| 163 | for (let i = 0; i < batch.length; i += 2) { |
| 164 | const suspenseIdNode = batch[i]; |
| 165 | const parentInstance = suspenseIdNode.parentNode; |
| 166 | if (!parentInstance) { |
| 167 | // We may have client-rendered this boundary already. Skip it. |
| 168 | continue; |
| 169 | } |
| 170 | const parentRect = parentInstance.getBoundingClientRect(); |
| 171 | if ( |
| 172 | !parentRect.left && |
| 173 | !parentRect.top && |
| 174 | !parentRect.width && |
| 175 | !parentRect.height |
| 176 | ) { |
| 177 | // If the parent instance is display: none then we don't animate this boundary. |
| 178 | // This can happen when this boundary is actually a child of a different boundary that |
| 179 | // isn't yet revealed or is about to be revealed, but in that case that boundary |
| 180 | // should do the exit/enter and not this one. Conveniently this also lets us skip |
| 181 | // this if it's just in a hidden tree in general. |
| 182 | // TODO: Should we skip it if it's out of viewport? It's possible that it gets |
| 183 | // brought into the viewport by changing size. |
| 184 | // TODO: There's a another case where an inner boundary is inside a fallback that |
| 185 | // is about to be deleted. In that case we should not run exit animations on the inner. |
| 186 | continue; |
| 187 | } |
| 188 | |
| 189 | // Apply exit animations to the immediate elements inside the fallback. |
| 190 | let node = suspenseIdNode; |
| 191 | let depth = 0; |
| 192 | while (node) { |
| 193 | if (node.nodeType === COMMENT_NODE) { |
| 194 | const data = node.data; |
| 195 | if (data === SUSPENSE_END_DATA) { |
| 196 | if (depth === 0) { |
| 197 | break; |
| 198 | } else { |
| 199 | depth--; |
| 200 | } |
| 201 | } else if ( |
| 202 | data === SUSPENSE_START_DATA || |
| 203 | data === SUSPENSE_PENDING_START_DATA || |
| 204 | data === SUSPENSE_QUEUED_START_DATA || |
| 205 | data === SUSPENSE_FALLBACK_START_DATA |
| 206 | ) { |
| 207 | depth++; |
| 208 | } |
| 209 | } else if (node.nodeType === ELEMENT_NODE) { |
| 210 | const exitElement = node; |
| 211 | const exitName = exitElement.getAttribute('vt-name'); |
| 212 | const pairedElement = appearingViewTransitions.get(exitName); |
| 213 | applyViewTransitionName( |
| 214 | exitElement, |
| 215 | pairedElement ? 'vt-share' : 'vt-exit', |
| 216 | ); |
| 217 | if (pairedElement) { |
| 218 | // Activate the other side as well. |
| 219 | applyViewTransitionName(pairedElement, 'vt-share'); |
| 220 | appearingViewTransitions.set(exitName, null); // mark claimed |
| 221 | } |
| 222 | // Next we'll look inside this element for pairs to trigger "share". |
| 223 | const disappearingElements = |
| 224 | exitElement.querySelectorAll('[vt-share]'); |
| 225 | for (let j = 0; j < disappearingElements.length; j++) { |
| 226 | const disappearingElement = disappearingElements[j]; |
| 227 | const name = disappearingElement.getAttribute('vt-name'); |
| 228 | const appearingElement = appearingViewTransitions.get(name); |
| 229 | if (appearingElement) { |
| 230 | applyViewTransitionName(disappearingElement, 'vt-share'); |
| 231 | applyViewTransitionName(appearingElement, 'vt-share'); |
| 232 | appearingViewTransitions.set(name, null); // mark claimed |
| 233 | } |
| 234 | } |
| 235 | // Relay the exit to nested ViewTransitions that opted in |
| 236 | const relayExitElements = |
| 237 | exitElement.querySelectorAll('[vt-parent-exit]'); |
| 238 | for (let j = 0; j < relayExitElements.length; j++) { |
| 239 | applyViewTransitionName(relayExitElements[j], 'vt-parent-exit'); |
| 240 | } |
| 241 | } |
| 242 | node = node.nextSibling; |
| 243 | } |
| 244 | |
| 245 | // Apply enter animations to the new nodes about to be inserted. |
| 246 | const contentNode = batch[i + 1]; |
| 247 | let enterElement = contentNode.firstElementChild; |
| 248 | while (enterElement) { |
| 249 | const paired = |
| 250 | appearingViewTransitions.get(enterElement.getAttribute('vt-name')) === |
| 251 | null; |
| 252 | if (!paired) { |
| 253 | applyViewTransitionName(enterElement, 'vt-enter'); |
| 254 | } |
| 255 | // Relay the enter to nested ViewTransitions that opted in |
| 256 | const relayEnterElements = |
| 257 | enterElement.querySelectorAll('[vt-parent-enter]'); |
| 258 | for (let j = 0; j < relayEnterElements.length; j++) { |
| 259 | applyViewTransitionName(relayEnterElements[j], 'vt-parent-enter'); |
| 260 | } |
| 261 | enterElement = enterElement.nextElementSibling; |
| 262 | } |
| 263 | |
| 264 | // Apply update animations to any parents and siblings that might be affected. |
| 265 | let ancestorElement = parentInstance; |
| 266 | do { |
| 267 | let childElement = ancestorElement.firstElementChild; |
| 268 | while (childElement) { |
| 269 | // TODO: Bail out if we can |
| 270 | const updateClassName = childElement.getAttribute('vt-update'); |
| 271 | if ( |
| 272 | updateClassName && |
| 273 | updateClassName !== 'none' && |
| 274 | !restoreQueue.includes(childElement) |
| 275 | ) { |
| 276 | // If we have already handled this element as part of another exit/enter/share, don't override. |
| 277 | applyViewTransitionName(childElement, 'vt-update'); |
| 278 | } |
| 279 | childElement = childElement.nextElementSibling; |
| 280 | } |
| 281 | } while ( |
| 282 | (ancestorElement = ancestorElement.parentNode) && |
| 283 | ancestorElement.nodeType === ELEMENT_NODE && |
| 284 | ancestorElement.getAttribute('vt-update') !== 'none' |
| 285 | ); |
| 286 | |
| 287 | // Find the appearing Suspensey Images inside the new content. |
| 288 | const appearingImages = contentNode.querySelectorAll( |
| 289 | 'img[src]:not([loading="lazy"])', |
| 290 | ); |
| 291 | // TODO: Consider marking shouldStartViewTransition if we found any images. |
| 292 | // But only once we can disable the root animation for that case. |
| 293 | suspenseyImages.push.apply(suspenseyImages, appearingImages); |
| 294 | } |
| 295 | if (shouldStartViewTransition) { |
| 296 | const transition = (document['__reactViewTransition'] = document[ |
| 297 | 'startViewTransition' |
| 298 | ]({ |
| 299 | update: () => { |
| 300 | revealBoundaries(batch); |
| 301 | const blockingPromises = [ |
| 302 | // Force layout to trigger font loading, we stash the actual value to trick minifiers. |
| 303 | document.documentElement.clientHeight, |
| 304 | // Block on fonts finishing loading before revealing these boundaries. |
| 305 | document.fonts.ready, |
| 306 | ]; |
| 307 | for (let i = 0; i < suspenseyImages.length; i++) { |
| 308 | const suspenseyImage = suspenseyImages[i]; |
| 309 | if (!suspenseyImage.complete) { |
| 310 | const rect = suspenseyImage.getBoundingClientRect(); |
| 311 | const inViewport = |
| 312 | rect.bottom > 0 && |
| 313 | rect.right > 0 && |
| 314 | rect.top < window.innerHeight && |
| 315 | rect.left < window.innerWidth; |
| 316 | if (inViewport) { |
| 317 | // TODO: Use decode() instead of the load event here once the fix in |
| 318 | // https://issues.chromium.org/issues/420748301 has propagated fully. |
| 319 | const loadingImage = new Promise(resolve => { |
| 320 | suspenseyImage.addEventListener('load', resolve); |
| 321 | suspenseyImage.addEventListener('error', resolve); |
| 322 | }); |
| 323 | blockingPromises.push(loadingImage); |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | return Promise.race([ |
| 328 | Promise.all(blockingPromises), |
| 329 | new Promise(resolve => { |
| 330 | const currentTime = performance.now(); |
| 331 | const msUntilTimeout = |
| 332 | // If the throttle would make us miss the target metric, then shorten the throttle. |
| 333 | // performance.now()'s zero value is assumed to be the start time of the metric. |
| 334 | currentTime < TARGET_VANITY_METRIC && |
| 335 | currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS |
| 336 | ? TARGET_VANITY_METRIC - currentTime |
| 337 | : // Otherwise it's throttled starting from last commit time. |
| 338 | SUSPENSEY_FONT_AND_IMAGE_TIMEOUT; |
| 339 | setTimeout(resolve, msUntilTimeout); |
| 340 | }), |
| 341 | ]); |
| 342 | }, |
| 343 | types: [], // TODO: Add a hard coded type for Suspense reveals. |
| 344 | })); |
| 345 | transition.ready.finally(() => { |
| 346 | // Restore all the names/classes that we applied to what they were before. |
| 347 | // We do it in reverse order in case there were duplicates so the first one wins. |
| 348 | for (let i = restoreQueue.length - 3; i >= 0; i -= 3) { |
| 349 | const element = restoreQueue[i]; |
| 350 | const elementStyle = element.style; |
| 351 | const previousName = restoreQueue[i + 1]; |
| 352 | elementStyle['viewTransitionName'] = previousName; |
| 353 | const previousClassName = restoreQueue[i + 1]; |
| 354 | elementStyle['viewTransitionClass'] = previousClassName; |
| 355 | if (element.getAttribute('style') === '') { |
| 356 | element.removeAttribute('style'); |
| 357 | } |
| 358 | } |
| 359 | }); |
| 360 | transition.finished.finally(() => { |
| 361 | if (document['__reactViewTransition'] === transition) { |
| 362 | document['__reactViewTransition'] = null; |
| 363 | } |
| 364 | }); |
| 365 | // Queue any future completions into its own batch since they won't have been |
| 366 | // snapshotted by this one. |
| 367 | window['$RB'] = []; |
| 368 | return; |
| 369 | } |
| 370 | // Fall through to reveal. |
| 371 | } catch (x) { |
| 372 | // Fall through to reveal. |
| 373 | } |
| 374 | // ViewTransitions v2 not supported or no ViewTransitions found. Reveal immediately. |
| 375 | revealBoundaries(batch); |
| 376 | } |
| 377 | |
| 378 | export function clientRenderBoundary( |
| 379 | suspenseBoundaryID, |
| 380 | errorDigest, |
| 381 | errorMsg, |
| 382 | errorStack, |
| 383 | errorComponentStack, |
| 384 | ) { |
| 385 | // Find the fallback's first element. |
| 386 | const suspenseIdNode = document.getElementById(suspenseBoundaryID); |
| 387 | if (!suspenseIdNode) { |
| 388 | // The user must have already navigated away from this tree. |
| 389 | // E.g. because the parent was hydrated. |
| 390 | return; |
| 391 | } |
| 392 | // Find the boundary around the fallback. This is always the previous node. |
| 393 | const suspenseNode = suspenseIdNode.previousSibling; |
| 394 | // Tag it to be client rendered. |
| 395 | suspenseNode.data = SUSPENSE_FALLBACK_START_DATA; |
| 396 | // assign error metadata to first sibling |
| 397 | const dataset = suspenseIdNode.dataset; |
| 398 | if (errorDigest != null) dataset['dgst'] = errorDigest; |
| 399 | if (errorMsg) dataset['msg'] = errorMsg; |
| 400 | if (errorStack) dataset['stck'] = errorStack; |
| 401 | if (errorComponentStack) dataset['cstck'] = errorComponentStack; |
| 402 | // Tell React to retry it if the parent already hydrated. |
| 403 | if (suspenseNode['_reactRetry']) { |
| 404 | suspenseNode['_reactRetry'](); |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | export function completeBoundary(suspenseBoundaryID, contentID) { |
| 409 | const contentNodeOuter = document.getElementById(contentID); |
| 410 | if (!contentNodeOuter) { |
| 411 | // If the client has failed hydration we may have already deleted the streaming |
| 412 | // segments. The server may also have emitted a complete instruction but cancelled |
| 413 | // the segment. Regardless we can ignore this case. |
| 414 | return; |
| 415 | } |
| 416 | |
| 417 | // Find the fallback's first element. |
| 418 | const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID); |
| 419 | if (!suspenseIdNodeOuter) { |
| 420 | // We'll never reveal this boundary so we can remove its content immediately. |
| 421 | // Otherwise we'll leave it in until we reveal it. |
| 422 | // This is important in case this specific boundary contains other boundaries |
| 423 | // that may get completed before we reveal this one. |
| 424 | contentNodeOuter.parentNode.removeChild(contentNodeOuter); |
| 425 | |
| 426 | // The user must have already navigated away from this tree. |
| 427 | // E.g. because the parent was hydrated. That's fine there's nothing to do |
| 428 | // but we have to make sure that we already deleted the container node. |
| 429 | return; |
| 430 | } |
| 431 | |
| 432 | // Mark this Suspense boundary as queued so we know not to client render it |
| 433 | // at the end of document load. |
| 434 | const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling; |
| 435 | suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA; |
| 436 | // Queue this boundary for the next batch |
| 437 | window['$RB'].push(suspenseIdNodeOuter, contentNodeOuter); |
| 438 | |
| 439 | if (window['$RB'].length === 2) { |
| 440 | // This is the first time we've pushed to the batch. We need to schedule a callback |
| 441 | // to flush the batch. This is delayed by the throttle heuristic. |
| 442 | if (typeof window['$RT'] !== 'number') { |
| 443 | // If we haven't had our rAF callback yet, schedule everything for the first paint. |
| 444 | requestAnimationFrame(window['$RV'].bind(null, window['$RB'])); |
| 445 | } else { |
| 446 | const currentTime = performance.now(); |
| 447 | const msUntilTimeout = |
| 448 | // If the throttle would make us miss the target metric, then shorten the throttle. |
| 449 | // performance.now()'s zero value is assumed to be the start time of the metric. |
| 450 | currentTime < TARGET_VANITY_METRIC && |
| 451 | currentTime > TARGET_VANITY_METRIC - FALLBACK_THROTTLE_MS |
| 452 | ? TARGET_VANITY_METRIC - currentTime |
| 453 | : // Otherwise it's throttled starting from last commit time. |
| 454 | window['$RT'] + FALLBACK_THROTTLE_MS - currentTime; |
| 455 | // We always schedule the flush in a timer even if it's very low or negative to allow |
| 456 | // for multiple completeBoundary calls that are already queued to have a chance to |
| 457 | // make the batch. |
| 458 | setTimeout(window['$RV'].bind(null, window['$RB']), msUntilTimeout); |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | export function completeBoundaryWithStyles( |
| 464 | suspenseBoundaryID, |
| 465 | contentID, |
| 466 | stylesheetDescriptors, |
| 467 | ) { |
| 468 | const precedences = new Map(); |
| 469 | const thisDocument = document; |
| 470 | let lastResource, node; |
| 471 | |
| 472 | // Seed the precedence list with existing resources and collect hoistable style tags |
| 473 | const nodes = thisDocument.querySelectorAll( |
| 474 | 'link[data-precedence],style[data-precedence]', |
| 475 | ); |
| 476 | const styleTagsToHoist = []; |
| 477 | for (let i = 0; (node = nodes[i++]); ) { |
| 478 | if (node.getAttribute('media') === 'not all') { |
| 479 | styleTagsToHoist.push(node); |
| 480 | } else { |
| 481 | if (node.tagName === 'LINK') { |
| 482 | window['$RM'].set(node.getAttribute('href'), node); |
| 483 | } |
| 484 | precedences.set(node.dataset['precedence'], (lastResource = node)); |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | let i = 0; |
| 489 | const dependencies = []; |
| 490 | let href, precedence, attr, loadingState, resourceEl, media; |
| 491 | |
| 492 | function cleanupWith(cb) { |
| 493 | this['_p'] = null; |
| 494 | cb(); |
| 495 | } |
| 496 | |
| 497 | // Sheets Mode |
| 498 | let sheetMode = true; |
| 499 | while (true) { |
| 500 | if (sheetMode) { |
| 501 | // Sheet Mode iterates over the stylesheet arguments and constructs them if new or checks them for |
| 502 | // dependency if they already existed |
| 503 | const stylesheetDescriptor = stylesheetDescriptors[i++]; |
| 504 | if (!stylesheetDescriptor) { |
| 505 | // enter <style> Mode |
| 506 | sheetMode = false; |
| 507 | i = 0; |
| 508 | continue; |
| 509 | } |
| 510 | |
| 511 | let avoidInsert = false; |
| 512 | let j = 0; |
| 513 | href = stylesheetDescriptor[j++]; |
| 514 | |
| 515 | if ((resourceEl = window['$RM'].get(href))) { |
| 516 | // We have an already inserted stylesheet. |
| 517 | loadingState = resourceEl['_p']; |
| 518 | avoidInsert = true; |
| 519 | } else { |
| 520 | // We haven't already processed this href so we need to construct a stylesheet and hoist it |
| 521 | // We construct it here and attach a loadingState. We also check whether it matches |
| 522 | // media before we include it in the dependency array. |
| 523 | resourceEl = thisDocument.createElement('link'); |
| 524 | resourceEl.href = href; |
| 525 | resourceEl.rel = 'stylesheet'; |
| 526 | resourceEl.dataset['precedence'] = precedence = |
| 527 | stylesheetDescriptor[j++]; |
| 528 | while ((attr = stylesheetDescriptor[j++])) { |
| 529 | resourceEl.setAttribute(attr, stylesheetDescriptor[j++]); |
| 530 | } |
| 531 | loadingState = resourceEl['_p'] = new Promise((resolve, reject) => { |
| 532 | resourceEl.onload = cleanupWith.bind(resourceEl, resolve); |
| 533 | resourceEl.onerror = cleanupWith.bind(resourceEl, reject); |
| 534 | }); |
| 535 | // Save this resource element so we can bailout if it is used again |
| 536 | window['$RM'].set(href, resourceEl); |
| 537 | } |
| 538 | media = resourceEl.getAttribute('media'); |
| 539 | if (loadingState && (!media || window['matchMedia'](media).matches)) { |
| 540 | dependencies.push(loadingState); |
| 541 | } |
| 542 | if (avoidInsert) { |
| 543 | // We have a link that is already in the document. We don't want to fall through to the insert path |
| 544 | continue; |
| 545 | } |
| 546 | } else { |
| 547 | // <style> mode iterates over not-yet-hoisted <style> tags with data-precedence and hoists them. |
| 548 | resourceEl = styleTagsToHoist[i++]; |
| 549 | if (!resourceEl) { |
| 550 | // we are done with all style tags |
| 551 | break; |
| 552 | } |
| 553 | |
| 554 | precedence = resourceEl.getAttribute('data-precedence'); |
| 555 | resourceEl.removeAttribute('media'); |
| 556 | } |
| 557 | |
| 558 | // resourceEl is either a newly constructed <link rel="stylesheet" ...> or a <style> tag requiring hoisting |
| 559 | const prior = precedences.get(precedence) || lastResource; |
| 560 | if (prior === lastResource) { |
| 561 | lastResource = resourceEl; |
| 562 | } |
| 563 | precedences.set(precedence, resourceEl); |
| 564 | |
| 565 | // Finally, we insert the newly constructed instance at an appropriate location |
| 566 | // in the Document. |
| 567 | if (prior) { |
| 568 | prior.parentNode.insertBefore(resourceEl, prior.nextSibling); |
| 569 | } else { |
| 570 | const head = thisDocument.head; |
| 571 | head.insertBefore(resourceEl, head.firstChild); |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | const suspenseIdNodeOuter = document.getElementById(suspenseBoundaryID); |
| 576 | if (suspenseIdNodeOuter) { |
| 577 | // Mark this Suspense boundary as queued so we know not to client render it |
| 578 | // at the end of document load. |
| 579 | const suspenseNodeOuter = suspenseIdNodeOuter.previousSibling; |
| 580 | suspenseNodeOuter.data = SUSPENSE_QUEUED_START_DATA; |
| 581 | } |
| 582 | |
| 583 | Promise.all(dependencies).then( |
| 584 | window['$RC'].bind(null, suspenseBoundaryID, contentID), |
| 585 | window['$RX'].bind(null, suspenseBoundaryID, 'CSS failed to load'), |
| 586 | ); |
| 587 | } |
| 588 | |
| 589 | export function completeSegment(containerID, placeholderID) { |
| 590 | const segmentContainer = document.getElementById(containerID); |
| 591 | const placeholderNode = document.getElementById(placeholderID); |
| 592 | // We always expect both nodes to exist here because, while we might |
| 593 | // have navigated away from the main tree, we still expect the detached |
| 594 | // tree to exist. |
| 595 | segmentContainer.parentNode.removeChild(segmentContainer); |
| 596 | while (segmentContainer.firstChild) { |
| 597 | placeholderNode.parentNode.insertBefore( |
| 598 | segmentContainer.firstChild, |
| 599 | placeholderNode, |
| 600 | ); |
| 601 | } |
| 602 | placeholderNode.parentNode.removeChild(placeholderNode); |
| 603 | } |
| 604 | |
| 605 | // This is the exact URL string we expect that Fizz renders if we provide a function action. |
| 606 | // We use this for hydration warnings. It needs to be in sync with Fizz. Maybe makes sense |
| 607 | // as a shared module for that reason. |
| 608 | const EXPECTED_FORM_ACTION_URL = |
| 609 | // eslint-disable-next-line no-script-url |
| 610 | "javascript:throw new Error('React form unexpectedly submitted.')"; |
| 611 | |
| 612 | export function listenToFormSubmissionsForReplaying() { |
| 613 | // A global replay queue ensures actions are replayed in order. |
| 614 | // This event listener should be above the React one. That way when |
| 615 | // we preventDefault in React's handling we also prevent this event |
| 616 | // from queing it. Since React listens to the root and the top most |
| 617 | // container you can use is the document, the window is fine. |
| 618 | // eslint-disable-next-line no-restricted-globals |
| 619 | addEventListener('submit', event => { |
| 620 | if (event.defaultPrevented) { |
| 621 | // We let earlier events to prevent the action from submitting. |
| 622 | return; |
| 623 | } |
| 624 | const form = event.target; |
| 625 | const submitter = event['submitter']; |
| 626 | let action = form.action; |
| 627 | let formDataSubmitter = submitter; |
| 628 | if (submitter) { |
| 629 | const submitterAction = submitter.getAttribute('formAction'); |
| 630 | if (submitterAction != null) { |
| 631 | // The submitter overrides the action. |
| 632 | action = submitterAction; |
| 633 | // If the submitter overrides the action, and it passes the test below, |
| 634 | // that means that it was a function action which conceptually has no name. |
| 635 | // Therefore, we exclude the submitter from the formdata. |
| 636 | formDataSubmitter = null; |
| 637 | } |
| 638 | } |
| 639 | if (action !== EXPECTED_FORM_ACTION_URL) { |
| 640 | // The form is a regular form action, we can bail. |
| 641 | return; |
| 642 | } |
| 643 | |
| 644 | // Prevent native navigation. |
| 645 | // This will also prevent other React's on the same page from listening. |
| 646 | event.preventDefault(); |
| 647 | |
| 648 | // Take a snapshot of the FormData at the time of the event. |
| 649 | const formData = new FormData(form, formDataSubmitter); |
| 650 | |
| 651 | // Queue for replaying later. This field could potentially be shared with multiple |
| 652 | // Reacts on the same page since each one will preventDefault for the next one. |
| 653 | // This means that this protocol is shared with any React version that shares the same |
| 654 | // javascript: URL placeholder value. So we might not be the first to declare it. |
| 655 | // We attach it to the form's root node, which is the shared environment context |
| 656 | // where we preserve sequencing and where we'll pick it up from during hydration. |
| 657 | // If there's no ownerDocument, then this is the document. |
| 658 | const root = form.ownerDocument || form; |
| 659 | (root['$$reactFormReplay'] = root['$$reactFormReplay'] || []).push( |
| 660 | form, |
| 661 | submitter, |
| 662 | formData, |
| 663 | ); |
| 664 | }); |
| 665 | } |