| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import type {DOMEventName} from '../events/DOMEventNames'; |
| 11 | import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; |
| 12 | import type { |
| 13 | BoundingRect, |
| 14 | IntersectionObserverOptions, |
| 15 | ObserveVisibleRectsCallback, |
| 16 | } from 'react-reconciler/src/ReactTestSelectors'; |
| 17 | import type {ReactContext, ReactScopeInstance} from 'shared/ReactTypes'; |
| 18 | import type {AncestorInfoDev} from './validateDOMNesting'; |
| 19 | import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; |
| 20 | import type { |
| 21 | CrossOriginEnum, |
| 22 | PreloadImplOptions, |
| 23 | PreloadModuleImplOptions, |
| 24 | PreinitStyleOptions, |
| 25 | PreinitScriptOptions, |
| 26 | PreinitModuleScriptOptions, |
| 27 | } from 'react-dom/src/shared/ReactDOMTypes'; |
| 28 | import type {TransitionTypes} from 'react/src/ReactTransitionType'; |
| 29 | |
| 30 | import {NotPending} from '../shared/ReactDOMFormActions'; |
| 31 | |
| 32 | import {setSrcObject} from './ReactDOMSrcObject'; |
| 33 | |
| 34 | import {getCurrentRootHostContainer} from 'react-reconciler/src/ReactFiberHostContext'; |
| 35 | import {runWithFiberInDEV} from 'react-reconciler/src/ReactCurrentFiber'; |
| 36 | |
| 37 | import hasOwnProperty from 'shared/hasOwnProperty'; |
| 38 | import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion'; |
| 39 | import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; |
| 40 | |
| 41 | export { |
| 42 | setCurrentUpdatePriority, |
| 43 | getCurrentUpdatePriority, |
| 44 | resolveUpdatePriority, |
| 45 | } from './ReactDOMUpdatePriority'; |
| 46 | import { |
| 47 | precacheFiberNode, |
| 48 | updateFiberProps, |
| 49 | getFiberCurrentPropsFromNode, |
| 50 | getInstanceFromNode, |
| 51 | getClosestInstanceFromNode, |
| 52 | getFiberFromScopeInstance, |
| 53 | getInstanceFromNode as getInstanceFromNodeDOMTree, |
| 54 | isContainerMarkedAsRoot, |
| 55 | detachDeletedInstance, |
| 56 | getResourcesFromRoot, |
| 57 | isMarkedHoistable, |
| 58 | markNodeAsHoistable, |
| 59 | markNodeAsPendingLoad, |
| 60 | clearPendingLoadOnNode, |
| 61 | isNodePendingLoad, |
| 62 | isOwnedInstance, |
| 63 | } from './ReactDOMComponentTree'; |
| 64 | import { |
| 65 | traverseFragmentInstancesAndTextInstances, |
| 66 | getFragmentParentInstanceOrContainerFiber, |
| 67 | getInstanceFromHostFiber, |
| 68 | isFiberFollowing, |
| 69 | isFiberPreceding, |
| 70 | getFragmentInstanceOrTextInstanceSiblings, |
| 71 | traverseFragmentInstancesAndTextInstancesDeeply, |
| 72 | fiberIsPortaledIntoHost, |
| 73 | getFragmentPortalContainerInfo, |
| 74 | isFiberContainedByFragment, |
| 75 | isFragmentContainedByFiber, |
| 76 | } from 'react-reconciler/src/ReactFiberTreeReflection'; |
| 77 | import {compareDocumentPositionForEmptyFragment} from 'shared/ReactDOMFragmentRefShared'; |
| 78 | |
| 79 | export {detachDeletedInstance}; |
| 80 | import {hasRole} from './DOMAccessibilityRoles'; |
| 81 | import type {SingletonType} from './ReactDOMComponent'; |
| 82 | import { |
| 83 | setInitialProperties, |
| 84 | updateProperties, |
| 85 | clearSingletonProperties, |
| 86 | hydrateProperties, |
| 87 | hydrateText, |
| 88 | diffHydratedProperties, |
| 89 | getPropsFromElement, |
| 90 | diffHydratedText, |
| 91 | trapClickOnNonInteractiveElement, |
| 92 | clearClickListener, |
| 93 | } from './ReactDOMComponent'; |
| 94 | import {hydrateInput} from './ReactDOMInput'; |
| 95 | import {hydrateTextarea} from './ReactDOMTextarea'; |
| 96 | import {hydrateSelect} from './ReactDOMSelect'; |
| 97 | import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; |
| 98 | import setTextContent from './setTextContent'; |
| 99 | import { |
| 100 | validateDOMNesting, |
| 101 | validateTextNesting, |
| 102 | updatedAncestorInfoDev, |
| 103 | } from './validateDOMNesting'; |
| 104 | import { |
| 105 | isEnabled as ReactBrowserEventEmitterIsEnabled, |
| 106 | setEnabled as ReactBrowserEventEmitterSetEnabled, |
| 107 | } from '../events/ReactDOMEventListener'; |
| 108 | import {SVG_NAMESPACE, MATH_NAMESPACE} from './DOMNamespaces'; |
| 109 | import { |
| 110 | ELEMENT_NODE, |
| 111 | TEXT_NODE, |
| 112 | COMMENT_NODE, |
| 113 | DOCUMENT_NODE, |
| 114 | DOCUMENT_TYPE_NODE, |
| 115 | DOCUMENT_FRAGMENT_NODE, |
| 116 | } from './HTMLNodeType'; |
| 117 | |
| 118 | import { |
| 119 | flushEventReplaying, |
| 120 | retryIfBlockedOn, |
| 121 | } from '../events/ReactDOMEventReplaying'; |
| 122 | |
| 123 | import { |
| 124 | enableCreateEventHandleAPI, |
| 125 | enableScopeAPI, |
| 126 | enableTrustedTypesIntegration, |
| 127 | disableLegacyMode, |
| 128 | enableMoveBefore, |
| 129 | disableCommentsAsDOMContainers, |
| 130 | enableSuspenseyImages, |
| 131 | enableSrcObject, |
| 132 | enableViewTransition, |
| 133 | enableHydrationChangeEvent, |
| 134 | enableFragmentRefsScrollIntoView, |
| 135 | enableProfilerTimer, |
| 136 | enableFragmentRefsInstanceHandles, |
| 137 | enableFragmentRefsTextNodes, |
| 138 | } from 'shared/ReactFeatureFlags'; |
| 139 | import { |
| 140 | HostComponent, |
| 141 | HostHoistable, |
| 142 | HostText, |
| 143 | HostSingleton, |
| 144 | } from 'react-reconciler/src/ReactWorkTags'; |
| 145 | import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; |
| 146 | import {validateLinkPropsForStyleResource} from '../shared/ReactDOMResourceValidation'; |
| 147 | import escapeSelectorAttributeValueInsideDoubleQuotes from './escapeSelectorAttributeValueInsideDoubleQuotes'; |
| 148 | import {flushSyncWork as flushSyncWorkOnAllRoots} from 'react-reconciler/src/ReactFiberWorkLoop'; |
| 149 | import {requestFormReset as requestFormResetOnFiber} from 'react-reconciler/src/ReactFiberHooks'; |
| 150 | |
| 151 | import ReactDOMSharedInternals from 'shared/ReactDOMSharedInternals'; |
| 152 | |
| 153 | export {default as rendererVersion} from 'shared/ReactVersion'; |
| 154 | |
| 155 | import noop from 'shared/noop'; |
| 156 | import estimateBandwidth from './estimateBandwidth'; |
| 157 | |
| 158 | export const rendererPackageName = 'react-dom'; |
| 159 | export const extraDevToolsConfig = null; |
| 160 | |
| 161 | export type Type = string; |
| 162 | export type Props = { |
| 163 | autoFocus?: boolean, |
| 164 | children?: mixed, |
| 165 | disabled?: boolean, |
| 166 | hidden?: boolean, |
| 167 | suppressHydrationWarning?: boolean, |
| 168 | dangerouslySetInnerHTML?: mixed, |
| 169 | style?: { |
| 170 | display?: string, |
| 171 | viewTransitionName?: string, |
| 172 | 'view-transition-name'?: string, |
| 173 | viewTransitionClass?: string, |
| 174 | 'view-transition-class'?: string, |
| 175 | margin?: string, |
| 176 | marginTop?: string, |
| 177 | 'margin-top'?: string, |
| 178 | marginBottom?: string, |
| 179 | 'margin-bottom'?: string, |
| 180 | ... |
| 181 | }, |
| 182 | bottom?: null | number, |
| 183 | left?: null | number, |
| 184 | right?: null | number, |
| 185 | top?: null | number, |
| 186 | is?: string, |
| 187 | size?: number, |
| 188 | value?: string, |
| 189 | defaultValue?: string, |
| 190 | checked?: boolean, |
| 191 | defaultChecked?: boolean, |
| 192 | multiple?: boolean, |
| 193 | type?: string, |
| 194 | src?: string | Blob | MediaSource | MediaStream, // TODO: Response |
| 195 | srcSet?: string, |
| 196 | loading?: 'eager' | 'lazy', |
| 197 | onLoad?: (event: any) => void, |
| 198 | ... |
| 199 | }; |
| 200 | type RawProps = { |
| 201 | [string]: mixed, |
| 202 | }; |
| 203 | export type EventTargetChildElement = { |
| 204 | type: string, |
| 205 | props: null | { |
| 206 | style?: { |
| 207 | position?: string, |
| 208 | zIndex?: number, |
| 209 | bottom?: string, |
| 210 | left?: string, |
| 211 | right?: string, |
| 212 | top?: string, |
| 213 | ... |
| 214 | }, |
| 215 | ... |
| 216 | }, |
| 217 | ... |
| 218 | }; |
| 219 | |
| 220 | export type Container = |
| 221 | | interface extends Element {_reactRootContainer?: FiberRoot} |
| 222 | | interface extends Document {_reactRootContainer?: FiberRoot} |
| 223 | | interface extends DocumentFragment {_reactRootContainer?: FiberRoot}; |
| 224 | export type Instance = Element; |
| 225 | export type TextInstance = Text; |
| 226 | |
| 227 | type InstanceWithFragmentHandles = Instance & { |
| 228 | reactFragments?: Set<FragmentInstanceType>, |
| 229 | }; |
| 230 | type HostNodeWithFragmentHandles = (Instance | TextInstance) & { |
| 231 | reactFragments?: Set<FragmentInstanceType>, |
| 232 | }; |
| 233 | |
| 234 | declare class ActivityInterface extends Comment {} |
| 235 | declare class SuspenseInterface extends Comment { |
| 236 | _reactRetry: void | (() => void); |
| 237 | } |
| 238 | |
| 239 | export type ActivityInstance = ActivityInterface; |
| 240 | export type SuspenseInstance = SuspenseInterface; |
| 241 | |
| 242 | type FormStateMarkerInstance = Comment; |
| 243 | export type HydratableInstance = |
| 244 | | Instance |
| 245 | | TextInstance |
| 246 | | ActivityInstance |
| 247 | | SuspenseInstance |
| 248 | | FormStateMarkerInstance; |
| 249 | export type PublicInstance = Element | Text; |
| 250 | export type HostContextDev = { |
| 251 | context: HostContextProd, |
| 252 | ancestorInfo: AncestorInfoDev, |
| 253 | }; |
| 254 | type HostContextProd = HostContextNamespace; |
| 255 | export type HostContext = HostContextDev | HostContextProd; |
| 256 | export type UpdatePayload = Array<mixed>; |
| 257 | export type ChildSet = void; // Unused |
| 258 | export type TimeoutHandle = TimeoutID; |
| 259 | export type NoTimeout = -1; |
| 260 | export type RendererInspectionConfig = $ReadOnly<{}>; |
| 261 | |
| 262 | export type TransitionStatus = FormStatus; |
| 263 | |
| 264 | export type ViewTransitionInstance = { |
| 265 | name: string, |
| 266 | group: mixin$Animatable, |
| 267 | imagePair: mixin$Animatable, |
| 268 | old: mixin$Animatable, |
| 269 | new: mixin$Animatable, |
| 270 | }; |
| 271 | |
| 272 | type SelectionInformation = { |
| 273 | focusedElem: null | HTMLElement, |
| 274 | selectionRange: mixed, |
| 275 | }; |
| 276 | |
| 277 | const SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; |
| 278 | |
| 279 | const ACTIVITY_START_DATA = '&'; |
| 280 | const ACTIVITY_END_DATA = '/&'; |
| 281 | const SUSPENSE_START_DATA = '$'; |
| 282 | const SUSPENSE_END_DATA = '/$'; |
| 283 | const SUSPENSE_PENDING_START_DATA = '$?'; |
| 284 | const SUSPENSE_QUEUED_START_DATA = '$~'; |
| 285 | const SUSPENSE_FALLBACK_START_DATA = '$!'; |
| 286 | const PREAMBLE_CONTRIBUTION_HTML = 'html'; |
| 287 | const PREAMBLE_CONTRIBUTION_BODY = 'body'; |
| 288 | const PREAMBLE_CONTRIBUTION_HEAD = 'head'; |
| 289 | const FORM_STATE_IS_MATCHING = 'F!'; |
| 290 | const FORM_STATE_IS_NOT_MATCHING = 'F'; |
| 291 | |
| 292 | const DOCUMENT_READY_STATE_LOADING = 'loading'; |
| 293 | |
| 294 | const STYLE = 'style'; |
| 295 | |
| 296 | opaque type HostContextNamespace = 0 | 1 | 2; |
| 297 | export const HostContextNamespaceNone: HostContextNamespace = 0; |
| 298 | const HostContextNamespaceSvg: HostContextNamespace = 1; |
| 299 | const HostContextNamespaceMath: HostContextNamespace = 2; |
| 300 | |
| 301 | let eventsEnabled: ?boolean = null; |
| 302 | let selectionInformation: null | SelectionInformation = null; |
| 303 | |
| 304 | export * from 'react-reconciler/src/ReactFiberConfigWithNoPersistence'; |
| 305 | |
| 306 | function getOwnerDocumentFromRootContainer( |
| 307 | rootContainerElement: Element | Document | DocumentFragment, |
| 308 | ): Document { |
| 309 | return rootContainerElement.nodeType === DOCUMENT_NODE |
| 310 | ? (rootContainerElement as any) |
| 311 | : rootContainerElement.ownerDocument; |
| 312 | } |
| 313 | |
| 314 | export function getRootHostContext( |
| 315 | rootContainerInstance: Container, |
| 316 | ): HostContext { |
| 317 | let type; |
| 318 | let context: HostContextProd; |
| 319 | const nodeType = rootContainerInstance.nodeType; |
| 320 | switch (nodeType) { |
| 321 | case DOCUMENT_NODE: |
| 322 | case DOCUMENT_FRAGMENT_NODE: { |
| 323 | type = nodeType === DOCUMENT_NODE ? '#document' : '#fragment'; |
| 324 | const root = (rootContainerInstance as any).documentElement; |
| 325 | if (root) { |
| 326 | const namespaceURI = root.namespaceURI; |
| 327 | context = namespaceURI |
| 328 | ? getOwnHostContext(namespaceURI) |
| 329 | : HostContextNamespaceNone; |
| 330 | } else { |
| 331 | context = HostContextNamespaceNone; |
| 332 | } |
| 333 | break; |
| 334 | } |
| 335 | default: { |
| 336 | const container: any = |
| 337 | !disableCommentsAsDOMContainers && nodeType === COMMENT_NODE |
| 338 | ? rootContainerInstance.parentNode |
| 339 | : rootContainerInstance; |
| 340 | type = container.tagName; |
| 341 | const namespaceURI = container.namespaceURI; |
| 342 | if (!namespaceURI) { |
| 343 | switch (type) { |
| 344 | case 'svg': |
| 345 | context = HostContextNamespaceSvg; |
| 346 | break; |
| 347 | case 'math': |
| 348 | context = HostContextNamespaceMath; |
| 349 | break; |
| 350 | default: |
| 351 | context = HostContextNamespaceNone; |
| 352 | break; |
| 353 | } |
| 354 | } else { |
| 355 | const ownContext = getOwnHostContext(namespaceURI); |
| 356 | context = getChildHostContextProd(ownContext, type); |
| 357 | } |
| 358 | break; |
| 359 | } |
| 360 | } |
| 361 | if (__DEV__) { |
| 362 | const validatedTag = type.toLowerCase(); |
| 363 | const ancestorInfo = updatedAncestorInfoDev(null, validatedTag); |
| 364 | return {context, ancestorInfo}; |
| 365 | } |
| 366 | return context; |
| 367 | } |
| 368 | |
| 369 | function getOwnHostContext(namespaceURI: string): HostContextNamespace { |
| 370 | switch (namespaceURI) { |
| 371 | case SVG_NAMESPACE: |
| 372 | return HostContextNamespaceSvg; |
| 373 | case MATH_NAMESPACE: |
| 374 | return HostContextNamespaceMath; |
| 375 | default: |
| 376 | return HostContextNamespaceNone; |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | function getChildHostContextProd( |
| 381 | parentNamespace: HostContextNamespace, |
| 382 | type: string, |
| 383 | ): HostContextNamespace { |
| 384 | if (parentNamespace === HostContextNamespaceNone) { |
| 385 | // No (or default) parent namespace: potential entry point. |
| 386 | switch (type) { |
| 387 | case 'svg': |
| 388 | return HostContextNamespaceSvg; |
| 389 | case 'math': |
| 390 | return HostContextNamespaceMath; |
| 391 | default: |
| 392 | return HostContextNamespaceNone; |
| 393 | } |
| 394 | } |
| 395 | if (parentNamespace === HostContextNamespaceSvg && type === 'foreignObject') { |
| 396 | // We're leaving SVG. |
| 397 | return HostContextNamespaceNone; |
| 398 | } |
| 399 | // By default, pass namespace below. |
| 400 | return parentNamespace; |
| 401 | } |
| 402 | |
| 403 | export function getChildHostContext( |
| 404 | parentHostContext: HostContext, |
| 405 | type: string, |
| 406 | ): HostContext { |
| 407 | if (__DEV__) { |
| 408 | const parentHostContextDev = parentHostContext as any as HostContextDev; |
| 409 | const context = getChildHostContextProd(parentHostContextDev.context, type); |
| 410 | const ancestorInfo = updatedAncestorInfoDev( |
| 411 | parentHostContextDev.ancestorInfo, |
| 412 | type, |
| 413 | ); |
| 414 | return {context, ancestorInfo}; |
| 415 | } |
| 416 | const parentNamespace = parentHostContext as any as HostContextProd; |
| 417 | return getChildHostContextProd(parentNamespace, type); |
| 418 | } |
| 419 | |
| 420 | export function getPublicInstance(instance: Instance): Instance { |
| 421 | return instance; |
| 422 | } |
| 423 | |
| 424 | export function prepareForCommit(containerInfo: Container): Object | null { |
| 425 | eventsEnabled = ReactBrowserEventEmitterIsEnabled(); |
| 426 | selectionInformation = getSelectionInformation(containerInfo); |
| 427 | let activeInstance = null; |
| 428 | if (enableCreateEventHandleAPI) { |
| 429 | const focusedElem = selectionInformation.focusedElem; |
| 430 | if (focusedElem !== null) { |
| 431 | activeInstance = getClosestInstanceFromNode(focusedElem); |
| 432 | } |
| 433 | } |
| 434 | ReactBrowserEventEmitterSetEnabled(false); |
| 435 | return activeInstance; |
| 436 | } |
| 437 | |
| 438 | export function beforeActiveInstanceBlur(internalInstanceHandle: Object): void { |
| 439 | if (enableCreateEventHandleAPI) { |
| 440 | ReactBrowserEventEmitterSetEnabled(true); |
| 441 | dispatchBeforeDetachedBlur( |
| 442 | (selectionInformation as any).focusedElem, |
| 443 | internalInstanceHandle, |
| 444 | ); |
| 445 | ReactBrowserEventEmitterSetEnabled(false); |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | export function afterActiveInstanceBlur(): void { |
| 450 | if (enableCreateEventHandleAPI) { |
| 451 | ReactBrowserEventEmitterSetEnabled(true); |
| 452 | dispatchAfterDetachedBlur((selectionInformation as any).focusedElem); |
| 453 | ReactBrowserEventEmitterSetEnabled(false); |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | export function resetAfterCommit(containerInfo: Container): void { |
| 458 | restoreSelection(selectionInformation, containerInfo); |
| 459 | ReactBrowserEventEmitterSetEnabled(eventsEnabled); |
| 460 | eventsEnabled = null; |
| 461 | selectionInformation = null; |
| 462 | } |
| 463 | |
| 464 | export function createHoistableInstance( |
| 465 | type: string, |
| 466 | props: Props, |
| 467 | rootContainerInstance: Container, |
| 468 | internalInstanceHandle: Object, |
| 469 | ): Instance { |
| 470 | const ownerDocument = getOwnerDocumentFromRootContainer( |
| 471 | rootContainerInstance, |
| 472 | ); |
| 473 | |
| 474 | const domElement: Instance = ownerDocument.createElement(type); |
| 475 | precacheFiberNode(internalInstanceHandle, domElement); |
| 476 | updateFiberProps(domElement, props); |
| 477 | setInitialProperties(domElement, type, props); |
| 478 | markNodeAsHoistable(domElement); |
| 479 | return domElement; |
| 480 | } |
| 481 | |
| 482 | let didWarnScriptTags = false; |
| 483 | function isScriptDataBlock(props: Props): boolean { |
| 484 | const scriptType = props.type; |
| 485 | if (typeof scriptType !== 'string' || scriptType === '') { |
| 486 | return false; |
| 487 | } |
| 488 | const lower = scriptType.toLowerCase(); |
| 489 | // Special non-MIME keywords recognized by the HTML spec |
| 490 | // TODO: May be fine to also not warn about having these types be parsed as "parser-inserted" |
| 491 | if ( |
| 492 | lower === 'module' || |
| 493 | lower === 'importmap' || |
| 494 | lower === 'speculationrules' |
| 495 | ) { |
| 496 | return false; |
| 497 | } |
| 498 | // JavaScript MIME types per https://mimesniff.spec.whatwg.org/#javascript-mime-type |
| 499 | switch (lower) { |
| 500 | case 'application/ecmascript': |
| 501 | case 'application/javascript': |
| 502 | case 'application/x-ecmascript': |
| 503 | case 'application/x-javascript': |
| 504 | case 'text/ecmascript': |
| 505 | case 'text/javascript': |
| 506 | case 'text/javascript1.0': |
| 507 | case 'text/javascript1.1': |
| 508 | case 'text/javascript1.2': |
| 509 | case 'text/javascript1.3': |
| 510 | case 'text/javascript1.4': |
| 511 | case 'text/javascript1.5': |
| 512 | case 'text/jscript': |
| 513 | case 'text/livescript': |
| 514 | case 'text/x-ecmascript': |
| 515 | case 'text/x-javascript': |
| 516 | return false; |
| 517 | } |
| 518 | // Any other non-empty type value means this is a data block |
| 519 | return true; |
| 520 | } |
| 521 | const warnedUnknownTags: { |
| 522 | [key: string]: boolean, |
| 523 | } = { |
| 524 | // There are working polyfills for <dialog>. Let people use it. |
| 525 | dialog: true, |
| 526 | // Electron ships a custom <webview> tag to display external web content in |
| 527 | // an isolated frame and process. |
| 528 | // This tag is not present in non Electron environments such as JSDom which |
| 529 | // is often used for testing purposes. |
| 530 | // @see https://electronjs.org/docs/api/webview-tag |
| 531 | webview: true, |
| 532 | }; |
| 533 | |
| 534 | export function createInstance( |
| 535 | type: string, |
| 536 | props: Props, |
| 537 | rootContainerInstance: Container, |
| 538 | hostContext: HostContext, |
| 539 | internalInstanceHandle: Object, |
| 540 | ): Instance { |
| 541 | let hostContextProd: HostContextProd; |
| 542 | if (__DEV__) { |
| 543 | // TODO: take namespace into account when validating. |
| 544 | const hostContextDev: HostContextDev = hostContext as any; |
| 545 | validateDOMNesting(type, hostContextDev.ancestorInfo); |
| 546 | hostContextProd = hostContextDev.context; |
| 547 | } else { |
| 548 | hostContextProd = hostContext as any; |
| 549 | } |
| 550 | |
| 551 | const ownerDocument = getOwnerDocumentFromRootContainer( |
| 552 | rootContainerInstance, |
| 553 | ); |
| 554 | |
| 555 | let domElement: Instance; |
| 556 | switch (hostContextProd) { |
| 557 | case HostContextNamespaceSvg: |
| 558 | domElement = ownerDocument.createElementNS(SVG_NAMESPACE, type); |
| 559 | break; |
| 560 | case HostContextNamespaceMath: |
| 561 | domElement = ownerDocument.createElementNS(MATH_NAMESPACE, type); |
| 562 | break; |
| 563 | default: |
| 564 | switch (type) { |
| 565 | case 'svg': { |
| 566 | domElement = ownerDocument.createElementNS(SVG_NAMESPACE, type); |
| 567 | break; |
| 568 | } |
| 569 | case 'math': { |
| 570 | domElement = ownerDocument.createElementNS(MATH_NAMESPACE, type); |
| 571 | break; |
| 572 | } |
| 573 | case 'script': { |
| 574 | // Create the script via .innerHTML so its "parser-inserted" flag is |
| 575 | // set to true and it does not execute |
| 576 | const div = ownerDocument.createElement('div'); |
| 577 | if (__DEV__) { |
| 578 | if ( |
| 579 | enableTrustedTypesIntegration && |
| 580 | !didWarnScriptTags && |
| 581 | // Data block scripts are not executed by UAs anyway so |
| 582 | // we don't need to warn: https://html.spec.whatwg.org/multipage/scripting.html#attr-script-type |
| 583 | !isScriptDataBlock(props) |
| 584 | ) { |
| 585 | console.error( |
| 586 | 'Encountered a script tag while rendering React component. ' + |
| 587 | 'Scripts inside React components are never executed when rendering ' + |
| 588 | 'on the client. Consider using template tag instead ' + |
| 589 | '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).', |
| 590 | ); |
| 591 | didWarnScriptTags = true; |
| 592 | } |
| 593 | } |
| 594 | div.innerHTML = '<script><' + '/script>'; |
| 595 | // This is guaranteed to yield a script element. |
| 596 | const firstChild = div.firstChild as any as HTMLScriptElement; |
| 597 | domElement = div.removeChild(firstChild); |
| 598 | break; |
| 599 | } |
| 600 | case 'select': { |
| 601 | if (typeof props.is === 'string') { |
| 602 | domElement = ownerDocument.createElement('select', {is: props.is}); |
| 603 | } else { |
| 604 | // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug. |
| 605 | // See discussion in https://github.com/facebook/react/pull/6896 |
| 606 | // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240 |
| 607 | domElement = ownerDocument.createElement('select'); |
| 608 | } |
| 609 | if (props.multiple) { |
| 610 | domElement.multiple = true; |
| 611 | } else if (props.size) { |
| 612 | // Setting a size greater than 1 causes a select to behave like `multiple=true`, where |
| 613 | // it is possible that no option is selected. |
| 614 | // |
| 615 | // This is only necessary when a select in "single selection mode". |
| 616 | domElement.size = props.size; |
| 617 | } |
| 618 | break; |
| 619 | } |
| 620 | default: { |
| 621 | if (typeof props.is === 'string') { |
| 622 | domElement = ownerDocument.createElement(type, {is: props.is}); |
| 623 | } else { |
| 624 | // Separate else branch instead of using `props.is || undefined` above because of a Firefox bug. |
| 625 | // See discussion in https://github.com/facebook/react/pull/6896 |
| 626 | // and discussion in https://bugzilla.mozilla.org/show_bug.cgi?id=1276240 |
| 627 | domElement = ownerDocument.createElement(type); |
| 628 | } |
| 629 | |
| 630 | if (__DEV__) { |
| 631 | if (type.indexOf('-') === -1) { |
| 632 | // We're not SVG/MathML and we don't have a dash, so we're not a custom element |
| 633 | // Even if you use `is`, these should be of known type and lower case. |
| 634 | if (type !== type.toLowerCase()) { |
| 635 | console.error( |
| 636 | '<%s /> is using incorrect casing. ' + |
| 637 | 'Use PascalCase for React components, ' + |
| 638 | 'or lowercase for HTML elements.', |
| 639 | type, |
| 640 | ); |
| 641 | } |
| 642 | if ( |
| 643 | // $FlowFixMe[method-unbinding] |
| 644 | Object.prototype.toString.call(domElement) === |
| 645 | '[object HTMLUnknownElement]' && |
| 646 | !hasOwnProperty.call(warnedUnknownTags, type) |
| 647 | ) { |
| 648 | warnedUnknownTags[type] = true; |
| 649 | console.error( |
| 650 | 'The tag <%s> is unrecognized in this browser. ' + |
| 651 | 'If you meant to render a React component, start its name with ' + |
| 652 | 'an uppercase letter.', |
| 653 | type, |
| 654 | ); |
| 655 | } |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | } |
| 660 | } |
| 661 | precacheFiberNode(internalInstanceHandle, domElement); |
| 662 | updateFiberProps(domElement, props); |
| 663 | return domElement; |
| 664 | } |
| 665 | |
| 666 | let didWarnForClone = false; |
| 667 | |
| 668 | export function cloneMutableInstance( |
| 669 | instance: Instance, |
| 670 | keepChildren: boolean, |
| 671 | ): Instance { |
| 672 | if (__DEV__) { |
| 673 | // Warn for problematic |
| 674 | const tagName = instance.tagName; |
| 675 | switch (tagName) { |
| 676 | case 'VIDEO': |
| 677 | case 'IFRAME': |
| 678 | if (!didWarnForClone) { |
| 679 | didWarnForClone = true; |
| 680 | // TODO: Once we have the ability to avoid cloning the root, suggest an absolutely |
| 681 | // positioned ViewTransition instead as the solution. |
| 682 | console.warn( |
| 683 | 'startGestureTransition() required cloning a <%s> element since it exists in ' + |
| 684 | 'both states of the gesture. This can be problematic since it will load it twice ' + |
| 685 | 'Try removing or hiding it with <Activity mode="offscreen"> in the optimistic state.', |
| 686 | tagName.toLowerCase(), |
| 687 | ); |
| 688 | } |
| 689 | break; |
| 690 | } |
| 691 | } |
| 692 | return instance.cloneNode(keepChildren); |
| 693 | } |
| 694 | |
| 695 | export function appendInitialChild( |
| 696 | parentInstance: Instance, |
| 697 | child: Instance | TextInstance, |
| 698 | ): void { |
| 699 | // Note: This should not use moveBefore() because initial are appended while disconnected. |
| 700 | parentInstance.appendChild(child); |
| 701 | } |
| 702 | |
| 703 | export function finalizeInitialChildren( |
| 704 | domElement: Instance, |
| 705 | type: string, |
| 706 | props: Props, |
| 707 | hostContext: HostContext, |
| 708 | ): boolean { |
| 709 | setInitialProperties(domElement, type, props); |
| 710 | switch (type) { |
| 711 | case 'button': |
| 712 | case 'input': |
| 713 | case 'select': |
| 714 | case 'textarea': |
| 715 | return !!props.autoFocus; |
| 716 | case 'img': |
| 717 | return true; |
| 718 | default: |
| 719 | return false; |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | export function finalizeHydratedChildren( |
| 724 | domElement: Instance, |
| 725 | type: string, |
| 726 | props: Props, |
| 727 | hostContext: HostContext, |
| 728 | ): boolean { |
| 729 | // TOOD: Consider unifying this with hydrateInstance. |
| 730 | if (!enableHydrationChangeEvent) { |
| 731 | return false; |
| 732 | } |
| 733 | switch (type) { |
| 734 | case 'input': |
| 735 | case 'select': |
| 736 | case 'textarea': |
| 737 | case 'img': |
| 738 | return true; |
| 739 | default: |
| 740 | return false; |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | export function shouldSetTextContent(type: string, props: Props): boolean { |
| 745 | return ( |
| 746 | type === 'textarea' || |
| 747 | type === 'noscript' || |
| 748 | typeof props.children === 'string' || |
| 749 | typeof props.children === 'number' || |
| 750 | typeof props.children === 'bigint' || |
| 751 | (typeof props.dangerouslySetInnerHTML === 'object' && |
| 752 | props.dangerouslySetInnerHTML !== null && |
| 753 | props.dangerouslySetInnerHTML.__html != null) |
| 754 | ); |
| 755 | } |
| 756 | |
| 757 | export function createTextInstance( |
| 758 | text: string, |
| 759 | rootContainerInstance: Container, |
| 760 | hostContext: HostContext, |
| 761 | internalInstanceHandle: Object, |
| 762 | ): TextInstance { |
| 763 | if (__DEV__) { |
| 764 | const hostContextDev = hostContext as any as HostContextDev; |
| 765 | const ancestor = hostContextDev.ancestorInfo.current; |
| 766 | if (ancestor != null) { |
| 767 | validateTextNesting( |
| 768 | text, |
| 769 | ancestor.tag, |
| 770 | hostContextDev.ancestorInfo.implicitRootScope, |
| 771 | ); |
| 772 | } |
| 773 | } |
| 774 | const textNode: TextInstance = getOwnerDocumentFromRootContainer( |
| 775 | rootContainerInstance, |
| 776 | ).createTextNode(text); |
| 777 | precacheFiberNode(internalInstanceHandle, textNode); |
| 778 | return textNode; |
| 779 | } |
| 780 | |
| 781 | export function cloneMutableTextInstance( |
| 782 | textInstance: TextInstance, |
| 783 | ): TextInstance { |
| 784 | return textInstance.cloneNode(false); |
| 785 | } |
| 786 | |
| 787 | let currentPopstateTransitionEvent: Event | null = null; |
| 788 | export function shouldAttemptEagerTransition(): boolean { |
| 789 | const event = window.event; |
| 790 | if (event && event.type === 'popstate') { |
| 791 | // This is a popstate event. Attempt to render any transition during this |
| 792 | // event synchronously. Unless we already attempted during this event. |
| 793 | if (event === currentPopstateTransitionEvent) { |
| 794 | // We already attempted to render this popstate transition synchronously. |
| 795 | // Any subsequent attempts must have happened as the result of a derived |
| 796 | // update, like startTransition inside useEffect, or useDV. Switch back to |
| 797 | // the default behavior for all remaining transitions during the current |
| 798 | // popstate event. |
| 799 | return false; |
| 800 | } else { |
| 801 | // Cache the current event in case a derived transition is scheduled. |
| 802 | // (Refer to previous branch.) |
| 803 | currentPopstateTransitionEvent = event; |
| 804 | return true; |
| 805 | } |
| 806 | } |
| 807 | // We're not inside a popstate event. |
| 808 | currentPopstateTransitionEvent = null; |
| 809 | return false; |
| 810 | } |
| 811 | |
| 812 | let schedulerEvent: void | Event = undefined; |
| 813 | export function trackSchedulerEvent(): void { |
| 814 | schedulerEvent = window.event; |
| 815 | } |
| 816 | |
| 817 | export function resolveEventType(): null | string { |
| 818 | const event = window.event; |
| 819 | return event && event !== schedulerEvent ? event.type : null; |
| 820 | } |
| 821 | |
| 822 | export function resolveEventTimeStamp(): number { |
| 823 | const event = window.event; |
| 824 | return event && event !== schedulerEvent ? event.timeStamp : -1.1; |
| 825 | } |
| 826 | |
| 827 | export const isPrimaryRenderer = true; |
| 828 | export const warnsIfNotActing = true; |
| 829 | // This initialization code may run even on server environments |
| 830 | // if a component just imports ReactDOM (e.g. for findDOMNode). |
| 831 | // Some environments might not have setTimeout or clearTimeout. |
| 832 | export const scheduleTimeout: any = |
| 833 | typeof setTimeout === 'function' ? setTimeout : (undefined as any); |
| 834 | export const cancelTimeout: any = |
| 835 | typeof clearTimeout === 'function' ? clearTimeout : (undefined as any); |
| 836 | export const noTimeout: -1 = -1; |
| 837 | const localPromise = typeof Promise === 'function' ? Promise : undefined; |
| 838 | const localRequestAnimationFrame = |
| 839 | typeof requestAnimationFrame === 'function' |
| 840 | ? requestAnimationFrame |
| 841 | : scheduleTimeout; |
| 842 | |
| 843 | export {getClosestInstanceFromNode as getInstanceFromNode}; |
| 844 | |
| 845 | export function preparePortalMount(portalInstance: Instance): void { |
| 846 | listenToAllSupportedEvents(portalInstance); |
| 847 | } |
| 848 | |
| 849 | export function prepareScopeUpdate( |
| 850 | scopeInstance: ReactScopeInstance, |
| 851 | internalInstanceHandle: Object, |
| 852 | ): void { |
| 853 | if (enableScopeAPI) { |
| 854 | precacheFiberNode(internalInstanceHandle, scopeInstance); |
| 855 | } |
| 856 | } |
| 857 | |
| 858 | export function getInstanceFromScope( |
| 859 | scopeInstance: ReactScopeInstance, |
| 860 | ): null | Object { |
| 861 | if (enableScopeAPI) { |
| 862 | return getFiberFromScopeInstance(scopeInstance); |
| 863 | } |
| 864 | return null; |
| 865 | } |
| 866 | |
| 867 | // ------------------- |
| 868 | // Microtasks |
| 869 | // ------------------- |
| 870 | export const supportsMicrotasks = true; |
| 871 | export const scheduleMicrotask: any = |
| 872 | typeof queueMicrotask === 'function' |
| 873 | ? queueMicrotask |
| 874 | : typeof localPromise !== 'undefined' |
| 875 | ? callback => |
| 876 | localPromise.resolve(null).then(callback).catch(handleErrorInNextTick) |
| 877 | : scheduleTimeout; // TODO: Determine the best fallback here. |
| 878 | |
| 879 | function handleErrorInNextTick(error: any) { |
| 880 | setTimeout(() => { |
| 881 | throw error; |
| 882 | }); |
| 883 | } |
| 884 | |
| 885 | // ------------------- |
| 886 | // Mutation |
| 887 | // ------------------- |
| 888 | |
| 889 | export const supportsMutation = true; |
| 890 | |
| 891 | export function commitMount( |
| 892 | domElement: Instance, |
| 893 | type: string, |
| 894 | newProps: Props, |
| 895 | internalInstanceHandle: Object, |
| 896 | ): void { |
| 897 | // Despite the naming that might imply otherwise, this method only |
| 898 | // fires if there is an `Update` effect scheduled during mounting. |
| 899 | // This happens if `finalizeInitialChildren` returns `true` (which it |
| 900 | // does to implement the `autoFocus` attribute on the client). But |
| 901 | // there are also other cases when this might happen (such as patching |
| 902 | // up text content during hydration mismatch). So we'll check this again. |
| 903 | switch (type) { |
| 904 | case 'button': |
| 905 | case 'input': |
| 906 | case 'select': |
| 907 | case 'textarea': |
| 908 | if (newProps.autoFocus) { |
| 909 | ( |
| 910 | domElement as any as |
| 911 | | HTMLButtonElement |
| 912 | | HTMLInputElement |
| 913 | | HTMLSelectElement |
| 914 | | HTMLTextAreaElement |
| 915 | ).focus(); |
| 916 | } |
| 917 | return; |
| 918 | case 'img': { |
| 919 | // The technique here is to assign the src or srcSet property to cause the browser |
| 920 | // to issue a new load event. If it hasn't loaded yet it'll fire whenever the load actually completes. |
| 921 | // If it has already loaded we missed it so the second load will still be the first one that executes |
| 922 | // any associated onLoad props. |
| 923 | // Even if we have srcSet we prefer to reassign src. The reason is that Firefox does not trigger a new |
| 924 | // load event when only srcSet is assigned. Chrome will trigger a load event if either is assigned so we |
| 925 | // only need to assign one. And Safari just never triggers a new load event which means this technique |
| 926 | // is already a noop regardless of which properties are assigned. We should revisit if browsers update |
| 927 | // this heuristic in the future. |
| 928 | if (newProps.src) { |
| 929 | const src = (newProps as any).src; |
| 930 | if (enableSrcObject && typeof src === 'object') { |
| 931 | // For object src, we can't just set the src again to the same blob URL because it might have |
| 932 | // already revoked if it loaded before this. However, we can create a new blob URL and set that. |
| 933 | // This is relatively cheap since the blob is already in memory but this might cause some |
| 934 | // duplicated work. |
| 935 | // TODO: We could maybe detect if load hasn't fired yet and if so reuse the URL. |
| 936 | try { |
| 937 | setSrcObject(domElement, type, src); |
| 938 | return; |
| 939 | } catch (x) { |
| 940 | // If URL.createObjectURL() errors, it was probably some other object type |
| 941 | // that should be toString:ed instead, so we just fall-through to the normal |
| 942 | // path. |
| 943 | } |
| 944 | } |
| 945 | (domElement as any as HTMLImageElement).src = src; |
| 946 | } else if (newProps.srcSet) { |
| 947 | (domElement as any as HTMLImageElement).srcset = ( |
| 948 | newProps as any |
| 949 | ).srcSet; |
| 950 | } |
| 951 | return; |
| 952 | } |
| 953 | } |
| 954 | } |
| 955 | |
| 956 | export function commitHydratedInstance( |
| 957 | domElement: Instance, |
| 958 | type: string, |
| 959 | props: Props, |
| 960 | internalInstanceHandle: Object, |
| 961 | ): void { |
| 962 | if (!enableHydrationChangeEvent) { |
| 963 | return; |
| 964 | } |
| 965 | // This fires in the commit phase if a hydrated instance needs to do further |
| 966 | // work in the commit phase. Similar to commitMount. However, this should not |
| 967 | // do things that would've already happened such as set auto focus since that |
| 968 | // would steal focus. It's only scheduled if finalizeHydratedChildren returns |
| 969 | // true. |
| 970 | switch (type) { |
| 971 | case 'input': { |
| 972 | hydrateInput( |
| 973 | domElement, |
| 974 | props.value, |
| 975 | props.defaultValue, |
| 976 | props.checked, |
| 977 | props.defaultChecked, |
| 978 | ); |
| 979 | break; |
| 980 | } |
| 981 | case 'select': { |
| 982 | hydrateSelect( |
| 983 | domElement, |
| 984 | props.value, |
| 985 | props.defaultValue, |
| 986 | props.multiple, |
| 987 | ); |
| 988 | break; |
| 989 | } |
| 990 | case 'textarea': |
| 991 | hydrateTextarea(domElement, props.value, props.defaultValue); |
| 992 | break; |
| 993 | case 'img': |
| 994 | // TODO: Should we replay onLoad events? |
| 995 | break; |
| 996 | } |
| 997 | } |
| 998 | |
| 999 | export function commitUpdate( |
| 1000 | domElement: Instance, |
| 1001 | type: string, |
| 1002 | oldProps: Props, |
| 1003 | newProps: Props, |
| 1004 | internalInstanceHandle: Object, |
| 1005 | ): void { |
| 1006 | // Diff and update the properties. |
| 1007 | updateProperties(domElement, type, oldProps, newProps); |
| 1008 | |
| 1009 | // Update the props handle so that we know which props are the ones with |
| 1010 | // with current event handlers. |
| 1011 | updateFiberProps(domElement, newProps); |
| 1012 | } |
| 1013 | |
| 1014 | export function resetTextContent(domElement: Instance): void { |
| 1015 | setTextContent(domElement, ''); |
| 1016 | } |
| 1017 | |
| 1018 | export function commitTextUpdate( |
| 1019 | textInstance: TextInstance, |
| 1020 | oldText: string, |
| 1021 | newText: string, |
| 1022 | ): void { |
| 1023 | textInstance.nodeValue = newText; |
| 1024 | } |
| 1025 | |
| 1026 | const supportsMoveBefore = |
| 1027 | // $FlowFixMe[prop-missing]: We're doing the feature detection here. |
| 1028 | enableMoveBefore && |
| 1029 | typeof window !== 'undefined' && |
| 1030 | typeof window.Element.prototype.moveBefore === 'function'; |
| 1031 | |
| 1032 | export function appendChild( |
| 1033 | parentInstance: Instance, |
| 1034 | child: Instance | TextInstance, |
| 1035 | ): void { |
| 1036 | if (supportsMoveBefore && child.parentNode !== null) { |
| 1037 | // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. |
| 1038 | parentInstance.moveBefore(child, null); |
| 1039 | } else { |
| 1040 | parentInstance.appendChild(child); |
| 1041 | } |
| 1042 | } |
| 1043 | |
| 1044 | function warnForReactChildrenConflict(container: Container): void { |
| 1045 | if (__DEV__) { |
| 1046 | if ((container as any).__reactWarnedAboutChildrenConflict) { |
| 1047 | return; |
| 1048 | } |
| 1049 | const props = getFiberCurrentPropsFromNode(container); |
| 1050 | if (props !== null) { |
| 1051 | const fiber = getInstanceFromNode(container); |
| 1052 | if (fiber !== null) { |
| 1053 | if ( |
| 1054 | typeof props.children === 'string' || |
| 1055 | typeof props.children === 'number' |
| 1056 | ) { |
| 1057 | (container as any).__reactWarnedAboutChildrenConflict = true; |
| 1058 | // Run the warning with the Fiber of the container for context of where the children are specified. |
| 1059 | // We could also maybe use the Portal. The current execution context is the child being added. |
| 1060 | runWithFiberInDEV(fiber, () => { |
| 1061 | console.error( |
| 1062 | 'Cannot use a ref on a React element as a container to `createRoot` or `createPortal` ' + |
| 1063 | 'if that element also sets "children" text content using React. It should be a leaf with no children. ' + |
| 1064 | "Otherwise it's ambiguous which children should be used.", |
| 1065 | ); |
| 1066 | }); |
| 1067 | } else if (props.dangerouslySetInnerHTML != null) { |
| 1068 | (container as any).__reactWarnedAboutChildrenConflict = true; |
| 1069 | runWithFiberInDEV(fiber, () => { |
| 1070 | console.error( |
| 1071 | 'Cannot use a ref on a React element as a container to `createRoot` or `createPortal` ' + |
| 1072 | 'if that element also sets "dangerouslySetInnerHTML" using React. It should be a leaf with no children. ' + |
| 1073 | "Otherwise it's ambiguous which children should be used.", |
| 1074 | ); |
| 1075 | }); |
| 1076 | } |
| 1077 | } |
| 1078 | } |
| 1079 | } |
| 1080 | } |
| 1081 | |
| 1082 | export function appendChildToContainer( |
| 1083 | container: Container, |
| 1084 | child: Instance | TextInstance, |
| 1085 | ): void { |
| 1086 | if (__DEV__) { |
| 1087 | warnForReactChildrenConflict(container); |
| 1088 | } |
| 1089 | let parentNode: DocumentFragment | Element; |
| 1090 | if (container.nodeType === DOCUMENT_NODE) { |
| 1091 | parentNode = (container as any).body; |
| 1092 | } else if ( |
| 1093 | !disableCommentsAsDOMContainers && |
| 1094 | container.nodeType === COMMENT_NODE |
| 1095 | ) { |
| 1096 | parentNode = container.parentNode as any; |
| 1097 | if (supportsMoveBefore && child.parentNode !== null) { |
| 1098 | // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. |
| 1099 | parentNode.moveBefore(child, container); |
| 1100 | } else { |
| 1101 | parentNode.insertBefore(child, container); |
| 1102 | } |
| 1103 | return; |
| 1104 | } else if (container.nodeName === 'HTML') { |
| 1105 | parentNode = container.ownerDocument.body as any; |
| 1106 | } else { |
| 1107 | parentNode = container as any; |
| 1108 | } |
| 1109 | if (supportsMoveBefore && child.parentNode !== null) { |
| 1110 | // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. |
| 1111 | parentNode.moveBefore(child, null); |
| 1112 | } else { |
| 1113 | parentNode.appendChild(child); |
| 1114 | } |
| 1115 | |
| 1116 | // This container might be used for a portal. |
| 1117 | // If something inside a portal is clicked, that click should bubble |
| 1118 | // through the React tree. However, on Mobile Safari the click would |
| 1119 | // never bubble through the *DOM* tree unless an ancestor with onclick |
| 1120 | // event exists. So we wouldn't see it and dispatch it. |
| 1121 | // This is why we ensure that non React root containers have inline onclick |
| 1122 | // defined. |
| 1123 | // https://github.com/facebook/react/issues/11918 |
| 1124 | const reactRootContainer = container._reactRootContainer; |
| 1125 | if ( |
| 1126 | // $FlowFixMe[invalid-compare] |
| 1127 | (reactRootContainer === null || reactRootContainer === undefined) && |
| 1128 | parentNode.onclick === null |
| 1129 | ) { |
| 1130 | // TODO: This cast may not be sound for SVG, MathML or custom elements. |
| 1131 | trapClickOnNonInteractiveElement(parentNode as any as HTMLElement); |
| 1132 | } |
| 1133 | } |
| 1134 | |
| 1135 | export function insertBefore( |
| 1136 | parentInstance: Instance, |
| 1137 | child: Instance | TextInstance, |
| 1138 | beforeChild: Instance | TextInstance | SuspenseInstance | ActivityInstance, |
| 1139 | ): void { |
| 1140 | if (supportsMoveBefore && child.parentNode !== null) { |
| 1141 | // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. |
| 1142 | parentInstance.moveBefore(child, beforeChild); |
| 1143 | } else { |
| 1144 | parentInstance.insertBefore(child, beforeChild); |
| 1145 | } |
| 1146 | } |
| 1147 | |
| 1148 | export function insertInContainerBefore( |
| 1149 | container: Container, |
| 1150 | child: Instance | TextInstance, |
| 1151 | beforeChild: Instance | TextInstance | SuspenseInstance | ActivityInstance, |
| 1152 | ): void { |
| 1153 | if (__DEV__) { |
| 1154 | warnForReactChildrenConflict(container); |
| 1155 | } |
| 1156 | let parentNode: DocumentFragment | Element; |
| 1157 | if (container.nodeType === DOCUMENT_NODE) { |
| 1158 | parentNode = (container as any).body; |
| 1159 | } else if ( |
| 1160 | !disableCommentsAsDOMContainers && |
| 1161 | container.nodeType === COMMENT_NODE |
| 1162 | ) { |
| 1163 | parentNode = container.parentNode as any; |
| 1164 | } else if (container.nodeName === 'HTML') { |
| 1165 | parentNode = container.ownerDocument.body as any; |
| 1166 | } else { |
| 1167 | parentNode = container as any; |
| 1168 | } |
| 1169 | if (supportsMoveBefore && child.parentNode !== null) { |
| 1170 | // $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore. |
| 1171 | parentNode.moveBefore(child, beforeChild); |
| 1172 | } else { |
| 1173 | parentNode.insertBefore(child, beforeChild); |
| 1174 | } |
| 1175 | } |
| 1176 | |
| 1177 | export function isSingletonScope(type: string): boolean { |
| 1178 | return type === 'head'; |
| 1179 | } |
| 1180 | |
| 1181 | function createEvent(type: DOMEventName, bubbles: boolean): Event { |
| 1182 | const event = document.createEvent('Event'); |
| 1183 | event.initEvent(type as any as string, bubbles, false); |
| 1184 | return event; |
| 1185 | } |
| 1186 | |
| 1187 | function dispatchBeforeDetachedBlur( |
| 1188 | target: HTMLElement, |
| 1189 | internalInstanceHandle: Object, |
| 1190 | ): void { |
| 1191 | if (enableCreateEventHandleAPI) { |
| 1192 | const event = createEvent('beforeblur', true); |
| 1193 | // Dispatch "beforeblur" directly on the target, |
| 1194 | // so it gets picked up by the event system and |
| 1195 | // can propagate through the React internal tree. |
| 1196 | // $FlowFixMe[prop-missing]: internal field |
| 1197 | event._detachedInterceptFiber = internalInstanceHandle; |
| 1198 | target.dispatchEvent(event); |
| 1199 | } |
| 1200 | } |
| 1201 | |
| 1202 | function dispatchAfterDetachedBlur(target: HTMLElement): void { |
| 1203 | if (enableCreateEventHandleAPI) { |
| 1204 | const event = createEvent('afterblur', false); |
| 1205 | // So we know what was detached, make the relatedTarget the |
| 1206 | // detached target on the "afterblur" event. |
| 1207 | (event as any).relatedTarget = target; |
| 1208 | // Dispatch the event on the document. |
| 1209 | document.dispatchEvent(event); |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | export function removeChild( |
| 1214 | parentInstance: Instance, |
| 1215 | child: Instance | TextInstance | SuspenseInstance | ActivityInstance, |
| 1216 | ): void { |
| 1217 | parentInstance.removeChild(child); |
| 1218 | } |
| 1219 | |
| 1220 | export function removeChildFromContainer( |
| 1221 | container: Container, |
| 1222 | child: Instance | TextInstance | SuspenseInstance | ActivityInstance, |
| 1223 | ): void { |
| 1224 | let parentNode: DocumentFragment | Element; |
| 1225 | if (container.nodeType === DOCUMENT_NODE) { |
| 1226 | parentNode = (container as any).body; |
| 1227 | } else if ( |
| 1228 | !disableCommentsAsDOMContainers && |
| 1229 | container.nodeType === COMMENT_NODE |
| 1230 | ) { |
| 1231 | parentNode = container.parentNode as any; |
| 1232 | } else if (container.nodeName === 'HTML') { |
| 1233 | parentNode = container.ownerDocument.body as any; |
| 1234 | } else { |
| 1235 | parentNode = container as any; |
| 1236 | } |
| 1237 | parentNode.removeChild(child); |
| 1238 | } |
| 1239 | |
| 1240 | function clearHydrationBoundary( |
| 1241 | parentInstance: Instance, |
| 1242 | hydrationInstance: SuspenseInstance | ActivityInstance, |
| 1243 | ): void { |
| 1244 | let node: Node = hydrationInstance; |
| 1245 | // Delete all nodes within this suspense boundary. |
| 1246 | // There might be nested nodes so we need to keep track of how |
| 1247 | // deep we are and only break out when we're back on top. |
| 1248 | let depth = 0; |
| 1249 | do { |
| 1250 | const nextNode = node.nextSibling; |
| 1251 | parentInstance.removeChild(node); |
| 1252 | if (nextNode && nextNode.nodeType === COMMENT_NODE) { |
| 1253 | const data = (nextNode as any).data as string; |
| 1254 | if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { |
| 1255 | if (depth === 0) { |
| 1256 | parentInstance.removeChild(nextNode); |
| 1257 | // Retry if any event replaying was blocked on this. |
| 1258 | retryIfBlockedOn(hydrationInstance); |
| 1259 | return; |
| 1260 | } else { |
| 1261 | depth--; |
| 1262 | } |
| 1263 | } else if ( |
| 1264 | data === SUSPENSE_START_DATA || |
| 1265 | data === SUSPENSE_PENDING_START_DATA || |
| 1266 | data === SUSPENSE_QUEUED_START_DATA || |
| 1267 | data === SUSPENSE_FALLBACK_START_DATA || |
| 1268 | data === ACTIVITY_START_DATA |
| 1269 | ) { |
| 1270 | depth++; |
| 1271 | } else if (data === PREAMBLE_CONTRIBUTION_HTML) { |
| 1272 | // If a preamble contribution marker is found within the bounds of this boundary, |
| 1273 | // then it contributed to the html tag and we need to reset it. |
| 1274 | const ownerDocument = parentInstance.ownerDocument; |
| 1275 | const documentElement: Element = ownerDocument.documentElement as any; |
| 1276 | clearSingletonPreambleContribution(documentElement); |
| 1277 | } else if (data === PREAMBLE_CONTRIBUTION_HEAD) { |
| 1278 | const ownerDocument = parentInstance.ownerDocument; |
| 1279 | const head: Element = ownerDocument.head as any; |
| 1280 | clearSingletonPreambleContribution(head); |
| 1281 | // We need to clear the head because this is the only singleton that can have children that |
| 1282 | // were part of this boundary but are not inside this boundary. |
| 1283 | clearHead(head); |
| 1284 | } else if (data === PREAMBLE_CONTRIBUTION_BODY) { |
| 1285 | const ownerDocument = parentInstance.ownerDocument; |
| 1286 | const body: Element = ownerDocument.body as any; |
| 1287 | clearSingletonPreambleContribution(body); |
| 1288 | } |
| 1289 | } |
| 1290 | // $FlowFixMe[incompatible-type] we bail out when we get a null |
| 1291 | node = nextNode; |
| 1292 | } while (node); |
| 1293 | // TODO: Warn, we didn't find the end comment boundary. |
| 1294 | // Retry if any event replaying was blocked on this. |
| 1295 | retryIfBlockedOn(hydrationInstance); |
| 1296 | } |
| 1297 | |
| 1298 | export function clearActivityBoundary( |
| 1299 | parentInstance: Instance, |
| 1300 | activityInstance: ActivityInstance, |
| 1301 | ): void { |
| 1302 | clearHydrationBoundary(parentInstance, activityInstance); |
| 1303 | } |
| 1304 | |
| 1305 | export function clearSuspenseBoundary( |
| 1306 | parentInstance: Instance, |
| 1307 | suspenseInstance: SuspenseInstance, |
| 1308 | ): void { |
| 1309 | clearHydrationBoundary(parentInstance, suspenseInstance); |
| 1310 | } |
| 1311 | |
| 1312 | function clearHydrationBoundaryFromContainer( |
| 1313 | container: Container, |
| 1314 | hydrationInstance: SuspenseInstance | ActivityInstance, |
| 1315 | ): void { |
| 1316 | let parentNode: DocumentFragment | Element; |
| 1317 | if (container.nodeType === DOCUMENT_NODE) { |
| 1318 | parentNode = (container as any).body; |
| 1319 | } else if ( |
| 1320 | !disableCommentsAsDOMContainers && |
| 1321 | container.nodeType === COMMENT_NODE |
| 1322 | ) { |
| 1323 | parentNode = container.parentNode as any; |
| 1324 | } else if (container.nodeName === 'HTML') { |
| 1325 | parentNode = container.ownerDocument.body as any; |
| 1326 | } else { |
| 1327 | parentNode = container as any; |
| 1328 | } |
| 1329 | clearHydrationBoundary(parentNode, hydrationInstance); |
| 1330 | // Retry if any event replaying was blocked on this. |
| 1331 | retryIfBlockedOn(container); |
| 1332 | } |
| 1333 | |
| 1334 | export function clearActivityBoundaryFromContainer( |
| 1335 | container: Container, |
| 1336 | activityInstance: ActivityInstance, |
| 1337 | ): void { |
| 1338 | clearHydrationBoundaryFromContainer(container, activityInstance); |
| 1339 | } |
| 1340 | |
| 1341 | export function clearSuspenseBoundaryFromContainer( |
| 1342 | container: Container, |
| 1343 | suspenseInstance: SuspenseInstance, |
| 1344 | ): void { |
| 1345 | clearHydrationBoundaryFromContainer(container, suspenseInstance); |
| 1346 | } |
| 1347 | |
| 1348 | function hideOrUnhideDehydratedBoundary( |
| 1349 | suspenseInstance: SuspenseInstance | ActivityInstance, |
| 1350 | isHidden: boolean, |
| 1351 | ) { |
| 1352 | let node: Node = suspenseInstance; |
| 1353 | // Unhide all nodes within this suspense boundary. |
| 1354 | let depth = 0; |
| 1355 | do { |
| 1356 | const nextNode = node.nextSibling; |
| 1357 | if (node.nodeType === ELEMENT_NODE) { |
| 1358 | const instance = node as any as HTMLElement & {_stashedDisplay?: string}; |
| 1359 | if (isHidden) { |
| 1360 | instance._stashedDisplay = instance.style.display; |
| 1361 | instance.style.display = 'none'; |
| 1362 | } else { |
| 1363 | instance.style.display = instance._stashedDisplay || ''; |
| 1364 | if (instance.getAttribute('style') === '') { |
| 1365 | instance.removeAttribute('style'); |
| 1366 | } |
| 1367 | } |
| 1368 | } else if (node.nodeType === TEXT_NODE) { |
| 1369 | const textNode = node as any as Text & {_stashedText?: string}; |
| 1370 | if (isHidden) { |
| 1371 | textNode._stashedText = textNode.nodeValue; |
| 1372 | textNode.nodeValue = ''; |
| 1373 | } else { |
| 1374 | textNode.nodeValue = textNode._stashedText || ''; |
| 1375 | } |
| 1376 | } |
| 1377 | if (nextNode && nextNode.nodeType === COMMENT_NODE) { |
| 1378 | const data = (nextNode as any).data as string; |
| 1379 | if (data === SUSPENSE_END_DATA) { |
| 1380 | if (depth === 0) { |
| 1381 | return; |
| 1382 | } else { |
| 1383 | depth--; |
| 1384 | } |
| 1385 | } else if ( |
| 1386 | data === SUSPENSE_START_DATA || |
| 1387 | data === SUSPENSE_PENDING_START_DATA || |
| 1388 | data === SUSPENSE_QUEUED_START_DATA || |
| 1389 | data === SUSPENSE_FALLBACK_START_DATA |
| 1390 | ) { |
| 1391 | depth++; |
| 1392 | } |
| 1393 | // TODO: Should we hide preamble contribution in this case? |
| 1394 | } |
| 1395 | // $FlowFixMe[incompatible-type] we bail out when we get a null |
| 1396 | node = nextNode; |
| 1397 | } while (node); |
| 1398 | } |
| 1399 | |
| 1400 | export function hideDehydratedBoundary( |
| 1401 | suspenseInstance: SuspenseInstance, |
| 1402 | ): void { |
| 1403 | hideOrUnhideDehydratedBoundary(suspenseInstance, true); |
| 1404 | } |
| 1405 | |
| 1406 | export function hideInstance(instance: Instance): void { |
| 1407 | // TODO: Does this work for all element types? What about MathML? Should we |
| 1408 | // pass host context to this method? |
| 1409 | instance = instance as any as HTMLElement; |
| 1410 | const style = instance.style; |
| 1411 | // $FlowFixMe[method-unbinding] |
| 1412 | if (typeof style.setProperty === 'function') { |
| 1413 | style.setProperty('display', 'none', 'important'); |
| 1414 | } else { |
| 1415 | style.display = 'none'; |
| 1416 | } |
| 1417 | } |
| 1418 | |
| 1419 | export function hideTextInstance(textInstance: TextInstance): void { |
| 1420 | textInstance.nodeValue = ''; |
| 1421 | } |
| 1422 | |
| 1423 | export function unhideDehydratedBoundary( |
| 1424 | dehydratedInstance: SuspenseInstance | ActivityInstance, |
| 1425 | ): void { |
| 1426 | hideOrUnhideDehydratedBoundary(dehydratedInstance, false); |
| 1427 | } |
| 1428 | |
| 1429 | export function unhideInstance(instance: Instance, props: Props): void { |
| 1430 | instance = instance as any as HTMLElement; |
| 1431 | const styleProp = props[STYLE]; |
| 1432 | const display = |
| 1433 | styleProp !== undefined && |
| 1434 | // $FlowFixMe[invalid-compare] |
| 1435 | styleProp !== null && |
| 1436 | styleProp.hasOwnProperty('display') |
| 1437 | ? styleProp.display |
| 1438 | : null; |
| 1439 | instance.style.display = |
| 1440 | display == null || typeof display === 'boolean' |
| 1441 | ? '' |
| 1442 | : // The value would've errored already if it wasn't safe. |
| 1443 | // eslint-disable-next-line react-internal/safe-string-coercion |
| 1444 | ('' + display).trim(); |
| 1445 | } |
| 1446 | |
| 1447 | export function unhideTextInstance( |
| 1448 | textInstance: TextInstance, |
| 1449 | text: string, |
| 1450 | ): void { |
| 1451 | textInstance.nodeValue = text; |
| 1452 | } |
| 1453 | |
| 1454 | function warnForBlockInsideInline(instance: HTMLElement) { |
| 1455 | if (__DEV__) { |
| 1456 | let nextNode = instance.firstChild; |
| 1457 | outer: while (nextNode != null) { |
| 1458 | let node: Node = nextNode; |
| 1459 | if ( |
| 1460 | node.nodeType === ELEMENT_NODE && |
| 1461 | getComputedStyle(node as any).display === 'block' |
| 1462 | ) { |
| 1463 | const fiber = |
| 1464 | getInstanceFromNode(node) || getInstanceFromNode(instance); |
| 1465 | runWithFiberInDEV( |
| 1466 | fiber, |
| 1467 | (parentTag: string, childTag: string) => { |
| 1468 | console.error( |
| 1469 | "You're about to start a <ViewTransition> around a display: inline " + |
| 1470 | 'element <%s>, which itself has a display: block element <%s> inside it. ' + |
| 1471 | 'This might trigger a bug in Safari which causes the View Transition to ' + |
| 1472 | 'be skipped with a duplicate name error.\n' + |
| 1473 | 'https://bugs.webkit.org/show_bug.cgi?id=290923', |
| 1474 | parentTag.toLocaleLowerCase(), |
| 1475 | childTag.toLocaleLowerCase(), |
| 1476 | ); |
| 1477 | }, |
| 1478 | instance.tagName, |
| 1479 | (node as any).tagName, |
| 1480 | ); |
| 1481 | break; |
| 1482 | } |
| 1483 | if (node.firstChild != null) { |
| 1484 | nextNode = node.firstChild; |
| 1485 | continue; |
| 1486 | } |
| 1487 | if (node === instance) { |
| 1488 | break; |
| 1489 | } |
| 1490 | while (node.nextSibling == null) { |
| 1491 | if (node.parentNode == null || node.parentNode === instance) { |
| 1492 | break; |
| 1493 | } |
| 1494 | node = node.parentNode; |
| 1495 | } |
| 1496 | nextNode = node.nextSibling; |
| 1497 | } |
| 1498 | } |
| 1499 | } |
| 1500 | |
| 1501 | function countClientRects(rects: Array<ClientRect>): number { |
| 1502 | if (rects.length === 1) { |
| 1503 | return 1; |
| 1504 | } |
| 1505 | // Count non-zero rects. |
| 1506 | let count = 0; |
| 1507 | for (let i = 0; i < rects.length; i++) { |
| 1508 | const rect = rects[i]; |
| 1509 | if (rect.width > 0 && rect.height > 0) { |
| 1510 | count++; |
| 1511 | } |
| 1512 | } |
| 1513 | return count; |
| 1514 | } |
| 1515 | |
| 1516 | export function applyViewTransitionName( |
| 1517 | instance: Instance, |
| 1518 | name: string, |
| 1519 | className: ?string, |
| 1520 | ): void { |
| 1521 | instance = instance as any as HTMLElement; |
| 1522 | // If the name isn't valid CSS identifier, base64 encode the name instead. |
| 1523 | // This doesn't let you select it in custom CSS selectors but it does work in current |
| 1524 | // browsers. |
| 1525 | const escapedName = |
| 1526 | CSS.escape(name) !== name ? 'r-' + btoa(name).replace(/=/g, '') : name; |
| 1527 | // $FlowFixMe[prop-missing] |
| 1528 | instance.style.viewTransitionName = escapedName; |
| 1529 | if (className != null) { |
| 1530 | // $FlowFixMe[prop-missing] |
| 1531 | instance.style.viewTransitionClass = className; |
| 1532 | } |
| 1533 | const computedStyle = getComputedStyle(instance); |
| 1534 | if (computedStyle.display === 'inline') { |
| 1535 | // WebKit has a bug where assigning a name to display: inline elements errors |
| 1536 | // if they have display: block children. We try to work around this bug in the |
| 1537 | // simple case by converting it automatically to display: inline-block. |
| 1538 | // https://bugs.webkit.org/show_bug.cgi?id=290923 |
| 1539 | const rects = instance.getClientRects(); |
| 1540 | if ( |
| 1541 | // $FlowFixMe[incompatible-type] |
| 1542 | countClientRects(rects) === 1 |
| 1543 | ) { |
| 1544 | // If the instance has a single client rect, that means that it can be |
| 1545 | // expressed as a display: inline-block or block. |
| 1546 | // This will cause layout thrash but we live with it since inline view transitions |
| 1547 | // are unusual. |
| 1548 | const style = instance.style; |
| 1549 | // If there's literally only one rect, then it's likely on a single line like an |
| 1550 | // inline-block. If it's multiple rects but all but one of them are empty it's |
| 1551 | // likely because it's a single block that caused a line break. |
| 1552 | style.display = rects.length === 1 ? 'inline-block' : 'block'; |
| 1553 | // Margin doesn't apply to inline so should be zero. However, padding top/bottom |
| 1554 | // applies to inline-block positioning which we can offset by setting the margin |
| 1555 | // to the negative padding to get it back into original position. |
| 1556 | style.marginTop = '-' + computedStyle.paddingTop; |
| 1557 | style.marginBottom = '-' + computedStyle.paddingBottom; |
| 1558 | } else { |
| 1559 | // This case cannot be easily fixed if it has blocks but it's also fine if |
| 1560 | // it doesn't have blocks. So we only warn in DEV about this being an issue. |
| 1561 | warnForBlockInsideInline(instance); |
| 1562 | } |
| 1563 | } |
| 1564 | } |
| 1565 | |
| 1566 | export function restoreViewTransitionName( |
| 1567 | instance: Instance, |
| 1568 | props: Props, |
| 1569 | ): void { |
| 1570 | instance = instance as any as HTMLElement; |
| 1571 | const style = instance.style; |
| 1572 | const styleProp = props[STYLE]; |
| 1573 | const viewTransitionName = |
| 1574 | styleProp != null |
| 1575 | ? styleProp.hasOwnProperty('viewTransitionName') |
| 1576 | ? styleProp.viewTransitionName |
| 1577 | : styleProp.hasOwnProperty('view-transition-name') |
| 1578 | ? styleProp['view-transition-name'] |
| 1579 | : null |
| 1580 | : null; |
| 1581 | // $FlowFixMe[prop-missing] |
| 1582 | style.viewTransitionName = |
| 1583 | viewTransitionName == null || typeof viewTransitionName === 'boolean' |
| 1584 | ? '' |
| 1585 | : // The value would've errored already if it wasn't safe. |
| 1586 | // eslint-disable-next-line react-internal/safe-string-coercion |
| 1587 | ('' + viewTransitionName).trim(); |
| 1588 | const viewTransitionClass = |
| 1589 | styleProp != null |
| 1590 | ? styleProp.hasOwnProperty('viewTransitionClass') |
| 1591 | ? styleProp.viewTransitionClass |
| 1592 | : styleProp.hasOwnProperty('view-transition-class') |
| 1593 | ? styleProp['view-transition-class'] |
| 1594 | : null |
| 1595 | : null; |
| 1596 | // $FlowFixMe[prop-missing] |
| 1597 | style.viewTransitionClass = |
| 1598 | viewTransitionClass == null || typeof viewTransitionClass === 'boolean' |
| 1599 | ? '' |
| 1600 | : // The value would've errored already if it wasn't safe. |
| 1601 | // eslint-disable-next-line react-internal/safe-string-coercion |
| 1602 | ('' + viewTransitionClass).trim(); |
| 1603 | if (style.display === 'inline-block') { |
| 1604 | // We might have overridden the style. Reset it to what it should be. |
| 1605 | if (styleProp == null) { |
| 1606 | style.display = style.margin = ''; |
| 1607 | } else { |
| 1608 | const display = styleProp.display; |
| 1609 | style.display = |
| 1610 | display == null || typeof display === 'boolean' ? '' : display; |
| 1611 | const margin = styleProp.margin; |
| 1612 | if (margin != null) { |
| 1613 | style.margin = margin; |
| 1614 | } else { |
| 1615 | const marginTop = styleProp.hasOwnProperty('marginTop') |
| 1616 | ? styleProp.marginTop |
| 1617 | : styleProp['margin-top']; |
| 1618 | style.marginTop = |
| 1619 | marginTop == null || typeof marginTop === 'boolean' ? '' : marginTop; |
| 1620 | const marginBottom = styleProp.hasOwnProperty('marginBottom') |
| 1621 | ? styleProp.marginBottom |
| 1622 | : styleProp['margin-bottom']; |
| 1623 | style.marginBottom = |
| 1624 | marginBottom == null || typeof marginBottom === 'boolean' |
| 1625 | ? '' |
| 1626 | : marginBottom; |
| 1627 | } |
| 1628 | } |
| 1629 | } |
| 1630 | } |
| 1631 | |
| 1632 | export function cancelViewTransitionName( |
| 1633 | instance: Instance, |
| 1634 | oldName: string, |
| 1635 | props: Props, |
| 1636 | ): void { |
| 1637 | // To cancel the "new" state and paint this instance as part of the parent, all we have to do |
| 1638 | // is remove the view-transition-name before we exit startViewTransition. |
| 1639 | restoreViewTransitionName(instance, props); |
| 1640 | // There isn't a way to cancel an "old" state but what we can do is hide it by animating it. |
| 1641 | // Since it is already removed from the old state of the parent, this technique only works |
| 1642 | // if the parent also isn't transitioning. Therefore we should only cancel the root most |
| 1643 | // ViewTransitions. |
| 1644 | const documentElement = instance.ownerDocument.documentElement; |
| 1645 | if (documentElement !== null) { |
| 1646 | documentElement.animate( |
| 1647 | {opacity: [0, 0], pointerEvents: ['none', 'none']}, |
| 1648 | // $FlowFixMe[incompatible-type] |
| 1649 | { |
| 1650 | duration: 0, |
| 1651 | fill: 'forwards', |
| 1652 | pseudoElement: '::view-transition-group(' + oldName + ')', |
| 1653 | }, |
| 1654 | ); |
| 1655 | } |
| 1656 | } |
| 1657 | |
| 1658 | export function cancelRootViewTransitionName(rootContainer: Container): void { |
| 1659 | const documentElement: null | HTMLElement = |
| 1660 | rootContainer.nodeType === DOCUMENT_NODE |
| 1661 | ? (rootContainer as any).documentElement |
| 1662 | : rootContainer.ownerDocument.documentElement; |
| 1663 | |
| 1664 | if ( |
| 1665 | !disableCommentsAsDOMContainers && |
| 1666 | rootContainer.nodeType === COMMENT_NODE |
| 1667 | ) { |
| 1668 | if (__DEV__) { |
| 1669 | console.warn( |
| 1670 | 'Cannot cancel root view transition on a comment node. All view transitions will be globally scoped.', |
| 1671 | ); |
| 1672 | } |
| 1673 | return; |
| 1674 | } |
| 1675 | |
| 1676 | if ( |
| 1677 | documentElement !== null && |
| 1678 | // $FlowFixMe[prop-missing] |
| 1679 | documentElement.style.viewTransitionName === '' |
| 1680 | ) { |
| 1681 | // $FlowFixMe[prop-missing] |
| 1682 | documentElement.style.viewTransitionName = 'none'; |
| 1683 | documentElement.animate( |
| 1684 | {opacity: [0, 0], pointerEvents: ['none', 'none']}, |
| 1685 | // $FlowFixMe[incompatible-type] |
| 1686 | { |
| 1687 | duration: 0, |
| 1688 | fill: 'forwards', |
| 1689 | pseudoElement: '::view-transition-group(root)', |
| 1690 | }, |
| 1691 | ); |
| 1692 | // By default the root ::view-transition selector captures all pointer events, |
| 1693 | // which means nothing gets interactive. We want to let whatever is not animating |
| 1694 | // remain interactive during the transition. To do that, we set the size to nothing |
| 1695 | // so that the transition doesn't capture any clicks. We don't set pointer-events |
| 1696 | // on this one as that would apply to all running transitions. This lets animations |
| 1697 | // that are running to block clicks so that they don't end up incorrectly hitting |
| 1698 | // whatever is below the animation. |
| 1699 | documentElement.animate( |
| 1700 | {width: [0, 0], height: [0, 0]}, |
| 1701 | // $FlowFixMe[incompatible-call] |
| 1702 | // $FlowFixMe[incompatible-type] |
| 1703 | { |
| 1704 | duration: 0, |
| 1705 | fill: 'forwards', |
| 1706 | pseudoElement: '::view-transition', |
| 1707 | }, |
| 1708 | ); |
| 1709 | } |
| 1710 | } |
| 1711 | |
| 1712 | export function restoreRootViewTransitionName(rootContainer: Container): void { |
| 1713 | let containerInstance: Instance; |
| 1714 | if (rootContainer.nodeType === DOCUMENT_NODE) { |
| 1715 | containerInstance = (rootContainer as any).body; |
| 1716 | } else if (rootContainer.nodeName === 'HTML') { |
| 1717 | containerInstance = rootContainer.ownerDocument.body as any; |
| 1718 | } else { |
| 1719 | // If the container is not the whole document, then we ideally should probably |
| 1720 | // clone the whole document outside of the React too. |
| 1721 | containerInstance = rootContainer as any; |
| 1722 | } |
| 1723 | if ( |
| 1724 | !disableCommentsAsDOMContainers && |
| 1725 | containerInstance.nodeType === COMMENT_NODE |
| 1726 | ) { |
| 1727 | return; |
| 1728 | } |
| 1729 | if ( |
| 1730 | // $FlowFixMe[prop-missing] |
| 1731 | containerInstance.style.viewTransitionName === 'root' |
| 1732 | ) { |
| 1733 | // If we moved the root view transition name to the container in a gesture |
| 1734 | // we need to restore it now. |
| 1735 | containerInstance.style.viewTransitionName = ''; |
| 1736 | } |
| 1737 | const documentElement: null | HTMLElement = |
| 1738 | containerInstance.ownerDocument.documentElement; |
| 1739 | if ( |
| 1740 | documentElement !== null && |
| 1741 | // $FlowFixMe[prop-missing] |
| 1742 | documentElement.style.viewTransitionName === 'none' |
| 1743 | ) { |
| 1744 | // $FlowFixMe[prop-missing] |
| 1745 | documentElement.style.viewTransitionName = ''; |
| 1746 | } |
| 1747 | } |
| 1748 | |
| 1749 | function getComputedTransform(style: CSSStyleDeclaration): string { |
| 1750 | // Gets the merged transform of all the short hands. |
| 1751 | const computedStyle: any = style; |
| 1752 | let transform: string = computedStyle.transform; |
| 1753 | if (transform === 'none') { |
| 1754 | transform = ''; |
| 1755 | } |
| 1756 | const scale: string = computedStyle.scale; |
| 1757 | if (scale !== 'none' && scale !== '') { |
| 1758 | const parts = scale.split(' '); |
| 1759 | transform = |
| 1760 | (parts.length === 3 ? 'scale3d' : 'scale') + |
| 1761 | '(' + |
| 1762 | parts.join(', ') + |
| 1763 | ') ' + |
| 1764 | transform; |
| 1765 | } |
| 1766 | const rotate: string = computedStyle.rotate; |
| 1767 | if (rotate !== 'none' && rotate !== '') { |
| 1768 | const parts = rotate.split(' '); |
| 1769 | if (parts.length === 1) { |
| 1770 | transform = 'rotate(' + parts[0] + ') ' + transform; |
| 1771 | } else if (parts.length === 2) { |
| 1772 | transform = |
| 1773 | 'rotate' + parts[0].toUpperCase() + '(' + parts[1] + ') ' + transform; |
| 1774 | } else { |
| 1775 | transform = 'rotate3d(' + parts.join(', ') + ') ' + transform; |
| 1776 | } |
| 1777 | } |
| 1778 | const translate: string = computedStyle.translate; |
| 1779 | if (translate !== 'none' && translate !== '') { |
| 1780 | const parts = translate.split(' '); |
| 1781 | transform = |
| 1782 | (parts.length === 3 ? 'translate3d' : 'translate') + |
| 1783 | '(' + |
| 1784 | parts.join(', ') + |
| 1785 | ') ' + |
| 1786 | transform; |
| 1787 | } |
| 1788 | return transform; |
| 1789 | } |
| 1790 | |
| 1791 | function moveOutOfViewport( |
| 1792 | originalStyle: CSSStyleDeclaration, |
| 1793 | element: HTMLElement, |
| 1794 | ): void { |
| 1795 | // Apply a transform that safely puts the whole element outside the viewport |
| 1796 | // while still letting it paint its "old" state to a snapshot. |
| 1797 | const transform = getComputedTransform(originalStyle); |
| 1798 | // Clear the long form properties. |
| 1799 | // $FlowFixMe[prop-missing] |
| 1800 | element.style.translate = 'none'; |
| 1801 | // $FlowFixMe[prop-missing] |
| 1802 | element.style.scale = 'none'; |
| 1803 | // $FlowFixMe[prop-missing] |
| 1804 | element.style.rotate = 'none'; |
| 1805 | // Apply a translate to move it way out of the viewport. This is applied first |
| 1806 | // so that it is in the coordinate space of the parent and not after applying |
| 1807 | // other transforms. That's why we need to merge the long form properties. |
| 1808 | // TODO: Ideally we'd adjust for the parent's rotate/scale. Otherwise when |
| 1809 | // we move back the ::view-transition-group we might overshoot or undershoot. |
| 1810 | element.style.transform = 'translate(-20000px, -20000px) ' + transform; |
| 1811 | } |
| 1812 | |
| 1813 | function moveOldFrameIntoViewport(keyframe: any): void { |
| 1814 | // In the resulting View Transition Animation, the first frame will be offset. |
| 1815 | const computedTransform: ?string = keyframe.transform; |
| 1816 | if (computedTransform != null) { |
| 1817 | let transform = computedTransform === 'none' ? '' : computedTransform; |
| 1818 | transform = 'translate(20000px, 20000px) ' + transform; |
| 1819 | keyframe.transform = transform; |
| 1820 | } |
| 1821 | } |
| 1822 | |
| 1823 | export function cloneRootViewTransitionContainer( |
| 1824 | rootContainer: Container, |
| 1825 | ): Instance { |
| 1826 | // This implies that we're not going to animate the root document but instead |
| 1827 | // the clone so we first clear the name of the root container. |
| 1828 | const documentElement: null | HTMLElement = |
| 1829 | rootContainer.nodeType === DOCUMENT_NODE |
| 1830 | ? (rootContainer as any).documentElement |
| 1831 | : rootContainer.ownerDocument.documentElement; |
| 1832 | if ( |
| 1833 | documentElement !== null && |
| 1834 | // $FlowFixMe[prop-missing] |
| 1835 | documentElement.style.viewTransitionName === '' |
| 1836 | ) { |
| 1837 | // $FlowFixMe[prop-missing] |
| 1838 | documentElement.style.viewTransitionName = 'none'; |
| 1839 | } |
| 1840 | |
| 1841 | let containerInstance: HTMLElement; |
| 1842 | if (rootContainer.nodeType === DOCUMENT_NODE) { |
| 1843 | containerInstance = (rootContainer as any).body; |
| 1844 | } else if (rootContainer.nodeName === 'HTML') { |
| 1845 | containerInstance = rootContainer.ownerDocument.body as any; |
| 1846 | } else if ( |
| 1847 | !disableCommentsAsDOMContainers && |
| 1848 | rootContainer.nodeType === COMMENT_NODE |
| 1849 | ) { |
| 1850 | throw new Error( |
| 1851 | 'Cannot use a startGestureTransition() with a comment node root.', |
| 1852 | ); |
| 1853 | } else { |
| 1854 | // If the container is not the whole document, then we ideally should probably |
| 1855 | // clone the whole document outside of the React too. |
| 1856 | containerInstance = rootContainer as any; |
| 1857 | } |
| 1858 | |
| 1859 | const containerParent = containerInstance.parentNode; |
| 1860 | if (containerParent === null) { |
| 1861 | throw new Error( |
| 1862 | 'Cannot use a startGestureTransition() on a detached root.', |
| 1863 | ); |
| 1864 | } |
| 1865 | |
| 1866 | const clone: HTMLElement = containerInstance.cloneNode(false); |
| 1867 | |
| 1868 | const computedStyle = getComputedStyle(containerInstance); |
| 1869 | |
| 1870 | if ( |
| 1871 | computedStyle.position === 'absolute' || |
| 1872 | computedStyle.position === 'fixed' |
| 1873 | ) { |
| 1874 | // If the style is already absolute, we don't have to do anything because it'll appear |
| 1875 | // in the same place. |
| 1876 | } else { |
| 1877 | // Otherwise we need to absolutely position the clone in the same location as the original. |
| 1878 | let positionedAncestor: HTMLElement = containerParent; |
| 1879 | while ( |
| 1880 | positionedAncestor.parentNode != null && |
| 1881 | positionedAncestor.parentNode.nodeType !== DOCUMENT_NODE |
| 1882 | ) { |
| 1883 | if (getComputedStyle(positionedAncestor).position !== 'static') { |
| 1884 | break; |
| 1885 | } |
| 1886 | // $FlowFixMe[incompatible-type]: This is refined. |
| 1887 | positionedAncestor = positionedAncestor.parentNode; |
| 1888 | } |
| 1889 | |
| 1890 | const positionedAncestorStyle: any = positionedAncestor.style; |
| 1891 | const containerInstanceStyle: any = containerInstance.style; |
| 1892 | // Clear the transform while we're measuring since it affects the bounding client rect. |
| 1893 | const prevAncestorTranslate = positionedAncestorStyle.translate; |
| 1894 | const prevAncestorScale = positionedAncestorStyle.scale; |
| 1895 | const prevAncestorRotate = positionedAncestorStyle.rotate; |
| 1896 | const prevAncestorTransform = positionedAncestorStyle.transform; |
| 1897 | const prevTranslate = containerInstanceStyle.translate; |
| 1898 | const prevScale = containerInstanceStyle.scale; |
| 1899 | const prevRotate = containerInstanceStyle.rotate; |
| 1900 | const prevTransform = containerInstanceStyle.transform; |
| 1901 | positionedAncestorStyle.translate = 'none'; |
| 1902 | positionedAncestorStyle.scale = 'none'; |
| 1903 | positionedAncestorStyle.rotate = 'none'; |
| 1904 | positionedAncestorStyle.transform = 'none'; |
| 1905 | containerInstanceStyle.translate = 'none'; |
| 1906 | containerInstanceStyle.scale = 'none'; |
| 1907 | containerInstanceStyle.rotate = 'none'; |
| 1908 | containerInstanceStyle.transform = 'none'; |
| 1909 | |
| 1910 | const ancestorRect = positionedAncestor.getBoundingClientRect(); |
| 1911 | const rect = containerInstance.getBoundingClientRect(); |
| 1912 | |
| 1913 | const cloneStyle = clone.style; |
| 1914 | cloneStyle.position = 'absolute'; |
| 1915 | cloneStyle.top = rect.top - ancestorRect.top + 'px'; |
| 1916 | cloneStyle.left = rect.left - ancestorRect.left + 'px'; |
| 1917 | cloneStyle.width = rect.width + 'px'; |
| 1918 | cloneStyle.height = rect.height + 'px'; |
| 1919 | cloneStyle.margin = '0px'; |
| 1920 | cloneStyle.boxSizing = 'border-box'; |
| 1921 | |
| 1922 | positionedAncestorStyle.translate = prevAncestorTranslate; |
| 1923 | positionedAncestorStyle.scale = prevAncestorScale; |
| 1924 | positionedAncestorStyle.rotate = prevAncestorRotate; |
| 1925 | positionedAncestorStyle.transform = prevAncestorTransform; |
| 1926 | containerInstanceStyle.translate = prevTranslate; |
| 1927 | containerInstanceStyle.scale = prevScale; |
| 1928 | containerInstanceStyle.rotate = prevRotate; |
| 1929 | containerInstanceStyle.transform = prevTransform; |
| 1930 | } |
| 1931 | |
| 1932 | // For this transition the container will act as the root. Nothing outside of it should |
| 1933 | // be affected anyway. This lets us transition from the cloned container to the original. |
| 1934 | // $FlowFixMe[prop-missing] |
| 1935 | clone.style.viewTransitionName = 'root'; |
| 1936 | |
| 1937 | // Move out of the viewport so that it's still painted for the snapshot but is not visible |
| 1938 | // for the frame where the snapshot happens. |
| 1939 | moveOutOfViewport(computedStyle, clone); |
| 1940 | |
| 1941 | // Insert the clone after the root container as a sibling. This may inject a body |
| 1942 | // as the next sibling of an existing body. document.body will still point to the |
| 1943 | // first one and any id selectors will still find the first one. That's why it's |
| 1944 | // important that it's after the existing node. |
| 1945 | containerInstance.parentNode.insertBefore( |
| 1946 | clone, |
| 1947 | containerInstance.nextSibling, |
| 1948 | ); |
| 1949 | |
| 1950 | return clone; |
| 1951 | } |
| 1952 | |
| 1953 | export function removeRootViewTransitionClone( |
| 1954 | rootContainer: Container, |
| 1955 | clone: Instance, |
| 1956 | ): void { |
| 1957 | let containerInstance: Instance; |
| 1958 | if (rootContainer.nodeType === DOCUMENT_NODE) { |
| 1959 | containerInstance = (rootContainer as any).body; |
| 1960 | } else if (rootContainer.nodeName === 'HTML') { |
| 1961 | containerInstance = rootContainer.ownerDocument.body as any; |
| 1962 | } else { |
| 1963 | // If the container is not the whole document, then we ideally should probably |
| 1964 | // clone the whole document outside of the React too. |
| 1965 | containerInstance = rootContainer as any; |
| 1966 | } |
| 1967 | const containerParent = containerInstance.parentNode; |
| 1968 | if (containerParent === null) { |
| 1969 | throw new Error( |
| 1970 | 'Cannot use a startGestureTransition() on a detached root.', |
| 1971 | ); |
| 1972 | } |
| 1973 | // We assume that the clone is still within the same parent. |
| 1974 | containerParent.removeChild(clone); |
| 1975 | |
| 1976 | // Now the root is on the containerInstance itself until we call restoreRootViewTransitionName. |
| 1977 | containerInstance.style.viewTransitionName = 'root'; |
| 1978 | } |
| 1979 | |
| 1980 | export type InstanceMeasurement = { |
| 1981 | rect: ClientRect | DOMRect, |
| 1982 | abs: boolean, // is absolutely positioned |
| 1983 | clip: boolean, // is a clipping parent |
| 1984 | view: boolean, // is in viewport bounds |
| 1985 | }; |
| 1986 | |
| 1987 | function createMeasurement( |
| 1988 | rect: ClientRect | DOMRect, |
| 1989 | computedStyle: CSSStyleDeclaration, |
| 1990 | element: Element, |
| 1991 | ): InstanceMeasurement { |
| 1992 | const ownerWindow = element.ownerDocument.defaultView; |
| 1993 | return { |
| 1994 | rect: rect, |
| 1995 | abs: |
| 1996 | // Absolutely positioned instances don't contribute their size to the parent. |
| 1997 | computedStyle.position === 'absolute' || |
| 1998 | computedStyle.position === 'fixed', |
| 1999 | clip: |
| 2000 | // If a ViewTransition boundary acts as a clipping parent group we should |
| 2001 | // always mark it to animate if its children do so that we can clip them. |
| 2002 | // This doesn't actually have any effect yet until browsers implement |
| 2003 | // layered capture and nested view transitions. |
| 2004 | computedStyle.clipPath !== 'none' || |
| 2005 | computedStyle.overflow !== 'visible' || |
| 2006 | computedStyle.filter !== 'none' || |
| 2007 | computedStyle.mask !== 'none' || |
| 2008 | computedStyle.mask !== 'none' || |
| 2009 | computedStyle.borderRadius !== '0px', |
| 2010 | view: |
| 2011 | // If the instance was within the bounds of the viewport. We don't care as |
| 2012 | // much about if it was fully occluded because then it can still pop out. |
| 2013 | rect.bottom >= 0 && |
| 2014 | rect.right >= 0 && |
| 2015 | rect.top <= ownerWindow.innerHeight && |
| 2016 | rect.left <= ownerWindow.innerWidth, |
| 2017 | }; |
| 2018 | } |
| 2019 | |
| 2020 | export function measureInstance(instance: Instance): InstanceMeasurement { |
| 2021 | const rect = instance.getBoundingClientRect(); |
| 2022 | const computedStyle = getComputedStyle(instance); |
| 2023 | return createMeasurement(rect, computedStyle, instance); |
| 2024 | } |
| 2025 | |
| 2026 | export function measureClonedInstance(instance: Instance): InstanceMeasurement { |
| 2027 | const measuredRect = instance.getBoundingClientRect(); |
| 2028 | // Adjust the DOMRect based on the translate that put it outside the viewport. |
| 2029 | // TODO: This might not be completely correct if the parent also has a transform. |
| 2030 | const rect = new DOMRect( |
| 2031 | measuredRect.x + 20000, |
| 2032 | measuredRect.y + 20000, |
| 2033 | measuredRect.width, |
| 2034 | measuredRect.height, |
| 2035 | ); |
| 2036 | const computedStyle = getComputedStyle(instance); |
| 2037 | return createMeasurement(rect, computedStyle, instance); |
| 2038 | } |
| 2039 | |
| 2040 | export function wasInstanceInViewport( |
| 2041 | measurement: InstanceMeasurement, |
| 2042 | ): boolean { |
| 2043 | return measurement.view; |
| 2044 | } |
| 2045 | |
| 2046 | export function hasInstanceChanged( |
| 2047 | oldMeasurement: InstanceMeasurement, |
| 2048 | newMeasurement: InstanceMeasurement, |
| 2049 | ): boolean { |
| 2050 | // Note: This is not guaranteed from the same instance in the case that the Instance of the |
| 2051 | // ViewTransition swaps out but it's still the same ViewTransition instance. |
| 2052 | if (newMeasurement.clip) { |
| 2053 | // If we're a clipping parent, we always animate if any of our children do so that we can clip |
| 2054 | // them. This doesn't yet until browsers implement layered capture and nested view transitions. |
| 2055 | return true; |
| 2056 | } |
| 2057 | const oldRect = oldMeasurement.rect; |
| 2058 | const newRect = newMeasurement.rect; |
| 2059 | return ( |
| 2060 | oldRect.y !== newRect.y || |
| 2061 | oldRect.x !== newRect.x || |
| 2062 | oldRect.height !== newRect.height || |
| 2063 | oldRect.width !== newRect.width |
| 2064 | ); |
| 2065 | } |
| 2066 | |
| 2067 | export function hasInstanceAffectedParent( |
| 2068 | oldMeasurement: InstanceMeasurement, |
| 2069 | newMeasurement: InstanceMeasurement, |
| 2070 | ): boolean { |
| 2071 | // Note: This is not guaranteed from the same instance in the case that the Instance of the |
| 2072 | // ViewTransition swaps out but it's still the same ViewTransition instance. |
| 2073 | // If the instance has resized, it might have affected the parent layout. |
| 2074 | if (newMeasurement.abs) { |
| 2075 | // Absolutely positioned elements don't affect the parent layout, unless they |
| 2076 | // previously were not absolutely positioned. |
| 2077 | return !oldMeasurement.abs; |
| 2078 | } |
| 2079 | const oldRect = oldMeasurement.rect; |
| 2080 | const newRect = newMeasurement.rect; |
| 2081 | return oldRect.height !== newRect.height || oldRect.width !== newRect.width; |
| 2082 | } |
| 2083 | |
| 2084 | // How long to wait for new fonts to load before just committing anyway. |
| 2085 | // This freezes the screen. It needs to be short enough that it doesn't cause too much of |
| 2086 | // an issue when it's a new load and slow, yet long enough that you have a chance to load |
| 2087 | // it. Otherwise we wait for no reason. The assumption here is that you likely have |
| 2088 | // either cached the font or preloaded it earlier. |
| 2089 | // This timeout is also used for Suspensey Images when they're blocking a View Transition. |
| 2090 | const SUSPENSEY_FONT_AND_IMAGE_TIMEOUT = 500; |
| 2091 | |
| 2092 | function customizeViewTransitionError( |
| 2093 | error: Object, |
| 2094 | ignoreAbort: boolean, |
| 2095 | ): mixed { |
| 2096 | if (typeof error === 'object' && error !== null) { |
| 2097 | switch (error.name) { |
| 2098 | case 'TimeoutError': { |
| 2099 | // We assume that the only reason a Timeout can happen is because the Navigation |
| 2100 | // promise. We expect any other work to either be fast or have a timeout (fonts). |
| 2101 | if (__DEV__) { |
| 2102 | // eslint-disable-next-line react-internal/prod-error-codes |
| 2103 | return new Error( |
| 2104 | 'A ViewTransition timed out because a Navigation stalled. ' + |
| 2105 | 'This can happen if a Navigation is blocked on React itself. ' + |
| 2106 | "Such as if it's resolved inside useEffect. " + |
| 2107 | 'This can be solved by moving the resolution to useLayoutEffect.', |
| 2108 | {cause: error}, |
| 2109 | ); |
| 2110 | } |
| 2111 | break; |
| 2112 | } |
| 2113 | case 'AbortError': { |
| 2114 | if (ignoreAbort) { |
| 2115 | return null; |
| 2116 | } |
| 2117 | if (__DEV__) { |
| 2118 | // eslint-disable-next-line react-internal/prod-error-codes |
| 2119 | return new Error( |
| 2120 | 'A ViewTransition was aborted early. This might be because you have ' + |
| 2121 | 'other View Transition libraries on the page and only one can run at ' + |
| 2122 | "a time. To avoid this, use only React's built-in <ViewTransition> " + |
| 2123 | 'to coordinate.', |
| 2124 | {cause: error}, |
| 2125 | ); |
| 2126 | } |
| 2127 | break; |
| 2128 | } |
| 2129 | case 'InvalidStateError': { |
| 2130 | if ( |
| 2131 | error.message === |
| 2132 | 'View transition was skipped because document visibility state is hidden.' || |
| 2133 | error.message === |
| 2134 | 'Skipping view transition because document visibility state has become hidden.' || |
| 2135 | error.message === |
| 2136 | 'Skipping view transition because viewport size changed.' || |
| 2137 | // Chrome uses a generic error message instead of specific reasons. It will log a |
| 2138 | // more specific reason in the console but the user might not look there. |
| 2139 | // Some of these errors are important to surface like duplicate name errors but |
| 2140 | // it's too noisy for unactionable cases like the document was hidden. Therefore, |
| 2141 | // we hide all of them and hopefully it surfaces in another browser. |
| 2142 | error.message === 'Transition was aborted because of invalid state' |
| 2143 | ) { |
| 2144 | // Skip logging this. This is not considered an error. |
| 2145 | return null; |
| 2146 | } |
| 2147 | break; |
| 2148 | } |
| 2149 | } |
| 2150 | } |
| 2151 | return error; |
| 2152 | } |
| 2153 | |
| 2154 | /** @noinline */ |
| 2155 | function forceLayout(ownerDocument: Document) { |
| 2156 | // This function exists to trick minifiers to not remove this unused member expression. |
| 2157 | return (ownerDocument.documentElement as any).clientHeight; |
| 2158 | } |
| 2159 | |
| 2160 | function waitForImageToLoad(this: HTMLImageElement, resolve: () => void) { |
| 2161 | // TODO: Use decode() instead of the load event here once the fix in |
| 2162 | // https://issues.chromium.org/issues/420748301 has propagated fully. |
| 2163 | this.addEventListener('load', resolve); |
| 2164 | this.addEventListener('error', resolve); |
| 2165 | } |
| 2166 | |
| 2167 | export function startViewTransition( |
| 2168 | suspendedState: null | SuspendedState, |
| 2169 | rootContainer: Container, |
| 2170 | transitionTypes: null | TransitionTypes, |
| 2171 | mutationCallback: () => void, |
| 2172 | layoutCallback: () => void, |
| 2173 | afterMutationCallback: () => void, |
| 2174 | spawnedWorkCallback: () => void, |
| 2175 | passiveCallback: () => mixed, |
| 2176 | errorCallback: mixed => void, |
| 2177 | blockedCallback: string => void, // Profiling-only |
| 2178 | finishedAnimation: () => void, // Profiling-only |
| 2179 | ): null | RunningViewTransition { |
| 2180 | const ownerDocument: Document = |
| 2181 | rootContainer.nodeType === DOCUMENT_NODE |
| 2182 | ? (rootContainer as any) |
| 2183 | : rootContainer.ownerDocument; |
| 2184 | try { |
| 2185 | // $FlowFixMe[prop-missing] |
| 2186 | const transition = ownerDocument.startViewTransition({ |
| 2187 | update() { |
| 2188 | // Note: We read the existence of a pending navigation before we apply the |
| 2189 | // mutations. That way we're not waiting on a navigation that we spawned |
| 2190 | // from this update. Only navigations that started before this commit. |
| 2191 | const ownerWindow = ownerDocument.defaultView; |
| 2192 | const pendingNavigation = |
| 2193 | ownerWindow.navigation && ownerWindow.navigation.transition; |
| 2194 | // $FlowFixMe[prop-missing] |
| 2195 | const previousFontLoadingStatus = ownerDocument.fonts.status; |
| 2196 | mutationCallback(); |
| 2197 | const blockingPromises: Array<Promise<any>> = []; |
| 2198 | if (previousFontLoadingStatus === 'loaded') { |
| 2199 | // Force layout calculation to trigger font loading. |
| 2200 | forceLayout(ownerDocument); |
| 2201 | if ( |
| 2202 | // $FlowFixMe[prop-missing] |
| 2203 | ownerDocument.fonts.status === 'loading' |
| 2204 | ) { |
| 2205 | // The mutation lead to new fonts being loaded. We should wait on them before continuing. |
| 2206 | // This avoids waiting for potentially unrelated fonts that were already loading before. |
| 2207 | // Either in an earlier transition or as part of a sync optimistic state. This doesn't |
| 2208 | // include preloads that happened earlier. |
| 2209 | blockingPromises.push(ownerDocument.fonts.ready); |
| 2210 | } |
| 2211 | } |
| 2212 | const blockingIndexSnapshot = blockingPromises.length; |
| 2213 | if (suspendedState !== null) { |
| 2214 | // Suspend on any images that still haven't loaded and are in the viewport. |
| 2215 | const suspenseyImages = suspendedState.suspenseyImages; |
| 2216 | let imgBytes = 0; |
| 2217 | for (let i = 0; i < suspenseyImages.length; i++) { |
| 2218 | const suspenseyImage = suspenseyImages[i]; |
| 2219 | if (!suspenseyImage.complete) { |
| 2220 | const rect = suspenseyImage.getBoundingClientRect(); |
| 2221 | const inViewport = |
| 2222 | rect.bottom > 0 && |
| 2223 | rect.right > 0 && |
| 2224 | rect.top < ownerWindow.innerHeight && |
| 2225 | rect.left < ownerWindow.innerWidth; |
| 2226 | if (inViewport) { |
| 2227 | imgBytes += estimateImageBytes(suspenseyImage); |
| 2228 | if (imgBytes > estimatedBytesWithinLimit) { |
| 2229 | // We don't think we'll be able to download all the images within |
| 2230 | // the timeout. Give up. Rewind to only block on fonts, if any. |
| 2231 | blockingPromises.length = blockingIndexSnapshot; |
| 2232 | break; |
| 2233 | } |
| 2234 | const loadingImage = new Promise( |
| 2235 | waitForImageToLoad.bind(suspenseyImage), |
| 2236 | ); |
| 2237 | blockingPromises.push(loadingImage); |
| 2238 | } |
| 2239 | } |
| 2240 | } |
| 2241 | } |
| 2242 | if (blockingPromises.length > 0) { |
| 2243 | if (enableProfilerTimer) { |
| 2244 | const blockedReason = |
| 2245 | blockingIndexSnapshot > 0 |
| 2246 | ? blockingPromises.length > blockingIndexSnapshot |
| 2247 | ? 'Waiting on Fonts and Images' |
| 2248 | : 'Waiting on Fonts' |
| 2249 | : 'Waiting on Images'; |
| 2250 | blockedCallback(blockedReason); |
| 2251 | } |
| 2252 | const blockingReady = Promise.race([ |
| 2253 | Promise.all(blockingPromises), |
| 2254 | new Promise(resolve => |
| 2255 | setTimeout(resolve, SUSPENSEY_FONT_AND_IMAGE_TIMEOUT), |
| 2256 | ), |
| 2257 | ]).then(layoutCallback, layoutCallback); |
| 2258 | const allReady = pendingNavigation |
| 2259 | ? Promise.allSettled([pendingNavigation.finished, blockingReady]) |
| 2260 | : blockingReady; |
| 2261 | return allReady.then(afterMutationCallback, afterMutationCallback); |
| 2262 | } |
| 2263 | layoutCallback(); |
| 2264 | if (pendingNavigation) { |
| 2265 | return pendingNavigation.finished.then( |
| 2266 | afterMutationCallback, |
| 2267 | afterMutationCallback, |
| 2268 | ); |
| 2269 | } else { |
| 2270 | afterMutationCallback(); |
| 2271 | } |
| 2272 | }, |
| 2273 | types: transitionTypes, |
| 2274 | }); |
| 2275 | // $FlowFixMe[prop-missing] |
| 2276 | ownerDocument.__reactViewTransition = transition; |
| 2277 | |
| 2278 | const viewTransitionAnimations: Array<Animation> = []; |
| 2279 | |
| 2280 | const readyCallback = () => { |
| 2281 | const documentElement: Element = ownerDocument.documentElement as any; |
| 2282 | // Loop through all View Transition Animations. |
| 2283 | // $FlowFixMe[prop-missing] |
| 2284 | // $FlowFixMe[incompatible-type] |
| 2285 | const animations = documentElement.getAnimations({subtree: true}); |
| 2286 | for (let i = 0; i < animations.length; i++) { |
| 2287 | const animation = animations[i]; |
| 2288 | const effect: KeyframeEffect = animation.effect as any; |
| 2289 | // $FlowFixMe[prop-missing] |
| 2290 | const pseudoElement: ?string = effect.pseudoElement; |
| 2291 | if ( |
| 2292 | pseudoElement != null && |
| 2293 | pseudoElement.startsWith('::view-transition') |
| 2294 | ) { |
| 2295 | viewTransitionAnimations.push(animation); |
| 2296 | const keyframes = effect.getKeyframes(); |
| 2297 | // Next, we're going to try to optimize this animation in case the auto-generated |
| 2298 | // width/height keyframes are unnecessary. |
| 2299 | let width; |
| 2300 | let height; |
| 2301 | let unchangedDimensions = true; |
| 2302 | for (let j = 0; j < keyframes.length; j++) { |
| 2303 | const keyframe = keyframes[j]; |
| 2304 | const w = keyframe.width; |
| 2305 | if (width === undefined) { |
| 2306 | width = w; |
| 2307 | } else if (width !== w) { |
| 2308 | unchangedDimensions = false; |
| 2309 | break; |
| 2310 | } |
| 2311 | const h = keyframe.height; |
| 2312 | if (height === undefined) { |
| 2313 | height = h; |
| 2314 | } else if (height !== h) { |
| 2315 | unchangedDimensions = false; |
| 2316 | break; |
| 2317 | } |
| 2318 | // We're clearing the keyframes in case we are going to apply the optimization. |
| 2319 | delete keyframe.width; |
| 2320 | delete keyframe.height; |
| 2321 | if (keyframe.transform === 'none') { |
| 2322 | delete keyframe.transform; |
| 2323 | } |
| 2324 | } |
| 2325 | if ( |
| 2326 | unchangedDimensions && |
| 2327 | width !== undefined && |
| 2328 | height !== undefined |
| 2329 | ) { |
| 2330 | // Replace the keyframes with ones that don't animate the width/height. |
| 2331 | // $FlowFixMe[incompatible-type] |
| 2332 | effect.setKeyframes(keyframes); |
| 2333 | // Read back the new animation to see what the underlying width/height of the pseudo-element was. |
| 2334 | const computedStyle = getComputedStyle( |
| 2335 | // $FlowFixMe[incompatible-type] |
| 2336 | effect.target, |
| 2337 | // $FlowFixMe[prop-missing] |
| 2338 | effect.pseudoElement, |
| 2339 | ); |
| 2340 | if ( |
| 2341 | computedStyle.width !== width || |
| 2342 | computedStyle.height !== height |
| 2343 | ) { |
| 2344 | // Oops. Turns out that the pseudo-element had a different width/height so we need to let it |
| 2345 | // be overridden. Add it back. |
| 2346 | const first = keyframes[0]; |
| 2347 | first.width = width; |
| 2348 | first.height = height; |
| 2349 | const last = keyframes[keyframes.length - 1]; |
| 2350 | last.width = width; |
| 2351 | last.height = height; |
| 2352 | // $FlowFixMe[incompatible-type] |
| 2353 | effect.setKeyframes(keyframes); |
| 2354 | } |
| 2355 | } |
| 2356 | } |
| 2357 | } |
| 2358 | spawnedWorkCallback(); |
| 2359 | }; |
| 2360 | const handleError = (error: mixed) => { |
| 2361 | // $FlowFixMe[prop-missing] |
| 2362 | if (ownerDocument.__reactViewTransition === transition) { |
| 2363 | // $FlowFixMe[prop-missing] |
| 2364 | ownerDocument.__reactViewTransition = null; |
| 2365 | } |
| 2366 | try { |
| 2367 | error = customizeViewTransitionError(error, false); |
| 2368 | if (error !== null) { |
| 2369 | errorCallback(error); |
| 2370 | } |
| 2371 | } finally { |
| 2372 | // Continue the reset of the work. |
| 2373 | // If the error happened in the snapshot phase before the update callback |
| 2374 | // was invoked, then we need to first finish the mutation and layout phases. |
| 2375 | // If they're already invoked it's still safe to call them due the status check. |
| 2376 | mutationCallback(); |
| 2377 | layoutCallback(); |
| 2378 | // Skip afterMutationCallback() since we're not animating. |
| 2379 | spawnedWorkCallback(); |
| 2380 | if (enableProfilerTimer) { |
| 2381 | finishedAnimation(); |
| 2382 | } |
| 2383 | } |
| 2384 | }; |
| 2385 | transition.ready.then(readyCallback, handleError); |
| 2386 | transition.finished.finally(() => { |
| 2387 | for (let i = 0; i < viewTransitionAnimations.length; i++) { |
| 2388 | // In Safari, we need to manually cancel all manually started animations |
| 2389 | // or it'll block or interfer with future transitions. |
| 2390 | // We can't use getAnimations() due to #35336 so we collect them in an array. |
| 2391 | viewTransitionAnimations[i].cancel(); |
| 2392 | } |
| 2393 | // $FlowFixMe[prop-missing] |
| 2394 | if (ownerDocument.__reactViewTransition === transition) { |
| 2395 | // $FlowFixMe[prop-missing] |
| 2396 | ownerDocument.__reactViewTransition = null; |
| 2397 | } |
| 2398 | if (enableProfilerTimer) { |
| 2399 | finishedAnimation(); |
| 2400 | } |
| 2401 | passiveCallback(); |
| 2402 | }); |
| 2403 | return transition; |
| 2404 | } catch (x) { |
| 2405 | // We use the error as feature detection. |
| 2406 | // The only thing that should throw is if startViewTransition is missing |
| 2407 | // or if it doesn't accept the object form. Other errors are async. |
| 2408 | // I.e. it's before the View Transitions v2 spec. We only support View |
| 2409 | // Transitions v2 otherwise we fallback to not animating to ensure that |
| 2410 | // we're not animating with the wrong animation mapped. |
| 2411 | // Flush remaining work synchronously. |
| 2412 | mutationCallback(); |
| 2413 | layoutCallback(); |
| 2414 | // Skip afterMutationCallback(). We don't need it since we're not animating. |
| 2415 | if (enableProfilerTimer) { |
| 2416 | finishedAnimation(); |
| 2417 | } |
| 2418 | spawnedWorkCallback(); |
| 2419 | // Skip passiveCallback(). Spawned work will schedule a task. |
| 2420 | return null; |
| 2421 | } |
| 2422 | } |
| 2423 | |
| 2424 | export type RunningViewTransition = { |
| 2425 | skipTransition(): void, |
| 2426 | finished: Promise<void>, |
| 2427 | ... |
| 2428 | }; |
| 2429 | |
| 2430 | function mergeTranslate(translateA: ?string, translateB: ?string): string { |
| 2431 | if (!translateA || translateA === 'none') { |
| 2432 | return translateB || ''; |
| 2433 | } |
| 2434 | if (!translateB || translateB === 'none') { |
| 2435 | // $FlowFixMe[constant-condition] |
| 2436 | return translateA || ''; |
| 2437 | } |
| 2438 | const partsA = translateA.split(' '); |
| 2439 | const partsB = translateB.split(' '); |
| 2440 | let i; |
| 2441 | let result = ''; |
| 2442 | for (i = 0; i < partsA.length && i < partsB.length; i++) { |
| 2443 | if (i > 0) { |
| 2444 | result += ' '; |
| 2445 | } |
| 2446 | result += 'calc(' + partsA[i] + ' + ' + partsB[i] + ')'; |
| 2447 | } |
| 2448 | for (; i < partsA.length; i++) { |
| 2449 | result += ' ' + partsA[i]; |
| 2450 | } |
| 2451 | for (; i < partsB.length; i++) { |
| 2452 | result += ' ' + partsB[i]; |
| 2453 | } |
| 2454 | return result; |
| 2455 | } |
| 2456 | |
| 2457 | function animateGesture( |
| 2458 | keyframes: any, |
| 2459 | targetElement: Element, |
| 2460 | pseudoElement: string, |
| 2461 | timeline: GestureTimeline, |
| 2462 | viewTransitionAnimations: Array<Animation>, |
| 2463 | customTimelineCleanup: Array<() => void>, |
| 2464 | rangeStart: number, |
| 2465 | rangeEnd: number, |
| 2466 | moveFirstFrameIntoViewport: boolean, |
| 2467 | moveAllFramesIntoViewport: boolean, |
| 2468 | ) { |
| 2469 | let width; |
| 2470 | let height; |
| 2471 | let unchangedDimensions = true; |
| 2472 | for (let i = 0; i < keyframes.length; i++) { |
| 2473 | const keyframe = keyframes[i]; |
| 2474 | // Delete any easing since we always apply linear easing to gestures. |
| 2475 | delete keyframe.easing; |
| 2476 | delete keyframe.computedOffset; |
| 2477 | const w = keyframe.width; |
| 2478 | if (width === undefined) { |
| 2479 | width = w; |
| 2480 | } else if (width !== w) { |
| 2481 | unchangedDimensions = false; |
| 2482 | } |
| 2483 | const h = keyframe.height; |
| 2484 | if (height === undefined) { |
| 2485 | height = h; |
| 2486 | } else if (height !== h) { |
| 2487 | unchangedDimensions = false; |
| 2488 | } |
| 2489 | // Chrome returns "auto" for width/height which is not a valid value to |
| 2490 | // animate to. Similarly, transform: "none" is actually lack of transform. |
| 2491 | if (keyframe.width === 'auto') { |
| 2492 | delete keyframe.width; |
| 2493 | } |
| 2494 | if (keyframe.height === 'auto') { |
| 2495 | delete keyframe.height; |
| 2496 | } |
| 2497 | if (keyframe.transform === 'none') { |
| 2498 | delete keyframe.transform; |
| 2499 | } |
| 2500 | if (moveAllFramesIntoViewport) { |
| 2501 | if (keyframe.transform == null) { |
| 2502 | // If a transform is not explicitly specified to override the auto |
| 2503 | // generated one on the pseudo element, then we need to adjust it to |
| 2504 | // put it back into the viewport. We don't know the offset relative to |
| 2505 | // the screen so instead we use the translate prop to do a relative |
| 2506 | // adjustment. |
| 2507 | // TODO: If the "transform" was manually overridden on the pseudo |
| 2508 | // element itself and no longer the auto generated one, then we shouldn't |
| 2509 | // adjust it. I'm not sure how to detect this. |
| 2510 | if (keyframe.translate == null || keyframe.translate === '') { |
| 2511 | // TODO: If there's a CSS rule targeting translate on the pseudo element |
| 2512 | // already we need to merge it. |
| 2513 | const elementTranslate: ?string = ( |
| 2514 | getComputedStyle(targetElement, pseudoElement) as any |
| 2515 | ).translate; |
| 2516 | keyframe.translate = mergeTranslate( |
| 2517 | elementTranslate, |
| 2518 | '20000px 20000px', |
| 2519 | ); |
| 2520 | } else { |
| 2521 | keyframe.translate = mergeTranslate( |
| 2522 | keyframe.translate, |
| 2523 | '20000px 20000px', |
| 2524 | ); |
| 2525 | } |
| 2526 | } |
| 2527 | } |
| 2528 | } |
| 2529 | if (moveFirstFrameIntoViewport) { |
| 2530 | // If this is the generated animation that does a FLIP matrix translation |
| 2531 | // from the old position, we need to adjust it from the out of viewport |
| 2532 | // position. If this is going from old to new it only applies to first |
| 2533 | // keyframe. Otherwise it applies to every keyframe. |
| 2534 | moveOldFrameIntoViewport(keyframes[0]); |
| 2535 | } |
| 2536 | if (unchangedDimensions && width !== undefined && height !== undefined) { |
| 2537 | // Read the underlying width/height of the pseudo-element. The previous animation |
| 2538 | // should have already been cancelled so we should observe the underlying element. |
| 2539 | const computedStyle = getComputedStyle(targetElement, pseudoElement); |
| 2540 | if (computedStyle.width === width && computedStyle.height === height) { |
| 2541 | for (let i = 0; i < keyframes.length; i++) { |
| 2542 | const keyframe = keyframes[i]; |
| 2543 | delete keyframe.width; |
| 2544 | delete keyframe.height; |
| 2545 | } |
| 2546 | } |
| 2547 | } |
| 2548 | |
| 2549 | // TODO: Reverse the reverse if the original direction is reverse. |
| 2550 | const reverse = rangeStart > rangeEnd; |
| 2551 | if (timeline instanceof AnimationTimeline) { |
| 2552 | // Native Timeline |
| 2553 | // $FlowFixMe[incompatible-type] |
| 2554 | const animation = targetElement.animate(keyframes, { |
| 2555 | pseudoElement: pseudoElement, |
| 2556 | // Set the timeline to the current gesture timeline to drive the updates. |
| 2557 | timeline: timeline, |
| 2558 | // We reset all easing functions to linear so that it feels like you |
| 2559 | // have direct impact on the transition and to avoid double bouncing |
| 2560 | // from scroll bouncing. |
| 2561 | easing: 'linear', |
| 2562 | // We fill in both direction for overscroll. |
| 2563 | fill: 'both', // TODO: Should we preserve the fill instead? |
| 2564 | // We play all gestures in reverse, except if we're in reverse direction |
| 2565 | // in which case we need to play it in reverse of the reverse. |
| 2566 | direction: reverse ? 'normal' : 'reverse', |
| 2567 | // Range start needs to be higher than range end. If it goes in reverse |
| 2568 | // we reverse the whole animation below. |
| 2569 | rangeStart: (reverse ? rangeEnd : rangeStart) + '%', |
| 2570 | rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', |
| 2571 | }); |
| 2572 | viewTransitionAnimations.push(animation); |
| 2573 | } else { |
| 2574 | // Custom Timeline |
| 2575 | // $FlowFixMe[incompatible-type] |
| 2576 | const animation = targetElement.animate(keyframes, { |
| 2577 | pseudoElement: pseudoElement, |
| 2578 | // We reset all easing functions to linear so that it feels like you |
| 2579 | // have direct impact on the transition and to avoid double bouncing |
| 2580 | // from scroll bouncing. |
| 2581 | easing: 'linear', |
| 2582 | // We fill in both direction for overscroll. |
| 2583 | fill: 'both', // TODO: Should we preserve the fill instead? |
| 2584 | // We play all gestures in reverse, except if we're in reverse direction |
| 2585 | // in which case we need to play it in reverse of the reverse. |
| 2586 | direction: reverse ? 'normal' : 'reverse', |
| 2587 | // We set the delay and duration to represent the span of the range. |
| 2588 | delay: reverse ? rangeEnd : rangeStart, |
| 2589 | duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart, |
| 2590 | }); |
| 2591 | viewTransitionAnimations.push(animation); |
| 2592 | // Let the custom timeline take control of driving the animation. |
| 2593 | const cleanup = timeline.animate(animation); |
| 2594 | if (cleanup) { |
| 2595 | customTimelineCleanup.push(cleanup); |
| 2596 | } |
| 2597 | } |
| 2598 | } |
| 2599 | |
| 2600 | export function startGestureTransition( |
| 2601 | suspendedState: null | SuspendedState, |
| 2602 | rootContainer: Container, |
| 2603 | timeline: GestureTimeline, |
| 2604 | rangeStart: number, |
| 2605 | rangeEnd: number, |
| 2606 | transitionTypes: null | TransitionTypes, |
| 2607 | mutationCallback: () => void, |
| 2608 | animateCallback: () => void, |
| 2609 | errorCallback: mixed => void, |
| 2610 | finishedAnimation: () => void, // Profiling-only |
| 2611 | ): null | RunningViewTransition { |
| 2612 | const ownerDocument: Document = |
| 2613 | rootContainer.nodeType === DOCUMENT_NODE |
| 2614 | ? (rootContainer as any) |
| 2615 | : rootContainer.ownerDocument; |
| 2616 | try { |
| 2617 | // Force layout before we start the Transition. This works around a bug in Safari |
| 2618 | // if one of the clones end up being a stylesheet that isn't loaded or uncached. |
| 2619 | // https://bugs.webkit.org/show_bug.cgi?id=290146 |
| 2620 | forceLayout(ownerDocument); |
| 2621 | // $FlowFixMe[prop-missing] |
| 2622 | const transition = ownerDocument.startViewTransition({ |
| 2623 | update: mutationCallback, |
| 2624 | types: transitionTypes, |
| 2625 | }); |
| 2626 | // $FlowFixMe[prop-missing] |
| 2627 | ownerDocument.__reactViewTransition = transition; |
| 2628 | const customTimelineCleanup: Array<() => void> = []; // Cleanup Animations started in a CustomTimeline |
| 2629 | const viewTransitionAnimations: Array<Animation> = []; |
| 2630 | const readyCallback = () => { |
| 2631 | const documentElement: Element = ownerDocument.documentElement as any; |
| 2632 | // Loop through all View Transition Animations. |
| 2633 | // $FlowFixMe[prop-missing] |
| 2634 | // $FlowFixMe[incompatible-type] |
| 2635 | const animations = documentElement.getAnimations({subtree: true}); |
| 2636 | // First do a pass to collect all known group and new items so we can look |
| 2637 | // up if they exist later. |
| 2638 | const foundGroups: Set<string> = new Set(); |
| 2639 | const foundNews: Set<string> = new Set(); |
| 2640 | // Collect the longest duration of any view-transition animation including delay. |
| 2641 | let longestDuration = 0; |
| 2642 | for (let i = 0; i < animations.length; i++) { |
| 2643 | const effect: KeyframeEffect = animations[i].effect as any; |
| 2644 | // $FlowFixMe[prop-missing] |
| 2645 | const pseudoElement: ?string = effect.pseudoElement; |
| 2646 | if (pseudoElement == null) { |
| 2647 | } else if ( |
| 2648 | pseudoElement.startsWith('::view-transition') && |
| 2649 | effect.target === documentElement |
| 2650 | ) { |
| 2651 | const timing = effect.getTiming(); |
| 2652 | const duration = |
| 2653 | // $FlowFixMe[prop-missing] |
| 2654 | typeof timing.duration === 'number' ? timing.duration : 0; |
| 2655 | // TODO: Consider interation count higher than 1. |
| 2656 | // $FlowFixMe[prop-missing] |
| 2657 | // $FlowFixMe[unsafe-addition] |
| 2658 | const durationWithDelay = timing.delay + duration; |
| 2659 | if (durationWithDelay > longestDuration) { |
| 2660 | longestDuration = durationWithDelay; |
| 2661 | } |
| 2662 | if (pseudoElement.startsWith('::view-transition-group')) { |
| 2663 | foundGroups.add(pseudoElement.slice(23)); |
| 2664 | } else if (pseudoElement.startsWith('::view-transition-new')) { |
| 2665 | // TODO: This is not really a sufficient detection because if the new |
| 2666 | // pseudo element might exist but have animations disabled on it. |
| 2667 | foundNews.add(pseudoElement.slice(21)); |
| 2668 | } |
| 2669 | } |
| 2670 | } |
| 2671 | const durationToRangeMultipler = |
| 2672 | (rangeEnd - rangeStart) / longestDuration; |
| 2673 | for (let i = 0; i < animations.length; i++) { |
| 2674 | const anim = animations[i]; |
| 2675 | if (anim.playState !== 'running') { |
| 2676 | continue; |
| 2677 | } |
| 2678 | const effect: KeyframeEffect = anim.effect as any; |
| 2679 | // $FlowFixMe[prop-missing] |
| 2680 | const pseudoElement: ?string = effect.pseudoElement; |
| 2681 | if ( |
| 2682 | pseudoElement != null && |
| 2683 | pseudoElement.startsWith('::view-transition') && |
| 2684 | effect.target === documentElement |
| 2685 | ) { |
| 2686 | // Ideally we could mutate the existing animation but unfortunately |
| 2687 | // the mutable APIs seem less tested and therefore are lacking or buggy. |
| 2688 | // Therefore we create a new animation instead. |
| 2689 | anim.cancel(); |
| 2690 | let isGeneratedGroupAnim = false; |
| 2691 | let isExitGroupAnim = false; |
| 2692 | if (pseudoElement.startsWith('::view-transition-group')) { |
| 2693 | const groupName = pseudoElement.slice(23); |
| 2694 | if (foundNews.has(groupName)) { |
| 2695 | // If this has both "new" and "old" state we expect this to be an auto-generated |
| 2696 | // animation that started outside the viewport. We need to adjust this first frame |
| 2697 | // to be inside the viewport. |
| 2698 | // $FlowFixMe[prop-missing] |
| 2699 | const animationName: ?string = anim.animationName; |
| 2700 | isGeneratedGroupAnim = |
| 2701 | animationName != null && |
| 2702 | // $FlowFixMe[prop-missing] |
| 2703 | animationName.startsWith('-ua-view-transition-group-anim-'); |
| 2704 | } else { |
| 2705 | // If this has only an "old" state then the pseudo element will be outside |
| 2706 | // the viewport. If any keyframes don't override "transform" we need to |
| 2707 | // adjust them. |
| 2708 | isExitGroupAnim = true; |
| 2709 | } |
| 2710 | // TODO: If this has only an old state and no new state, |
| 2711 | } |
| 2712 | // Adjust the range based on how long the animation would've ran as time based. |
| 2713 | // Since we're running animations in reverse from how they normally would run, |
| 2714 | // therefore the timing is from the rangeEnd to the start. |
| 2715 | const timing = effect.getTiming(); |
| 2716 | const duration = |
| 2717 | // $FlowFixMe[prop-missing] |
| 2718 | typeof timing.duration === 'number' ? timing.duration : 0; |
| 2719 | let adjustedRangeStart = |
| 2720 | // $FlowFixMe[unsafe-addition] |
| 2721 | // $FlowFixMe[prop-missing] |
| 2722 | rangeEnd - (duration + timing.delay) * durationToRangeMultipler; |
| 2723 | let adjustedRangeEnd = |
| 2724 | rangeEnd - |
| 2725 | // $FlowFixMe[prop-missing] |
| 2726 | // $FlowFixMe[unsafe-arithmetic] |
| 2727 | timing.delay * durationToRangeMultipler; |
| 2728 | if ( |
| 2729 | timing.direction === 'reverse' || |
| 2730 | timing.direction === 'alternate-reverse' |
| 2731 | ) { |
| 2732 | // This animation was originally in reverse so we have to play it in flipped range. |
| 2733 | const temp = adjustedRangeStart; |
| 2734 | adjustedRangeStart = adjustedRangeEnd; |
| 2735 | adjustedRangeEnd = temp; |
| 2736 | } |
| 2737 | animateGesture( |
| 2738 | effect.getKeyframes(), |
| 2739 | // $FlowFixMe[incompatible-type]: Always documentElement atm. |
| 2740 | effect.target, |
| 2741 | pseudoElement, |
| 2742 | timeline, |
| 2743 | viewTransitionAnimations, |
| 2744 | customTimelineCleanup, |
| 2745 | adjustedRangeStart, |
| 2746 | adjustedRangeEnd, |
| 2747 | isGeneratedGroupAnim, |
| 2748 | isExitGroupAnim, |
| 2749 | ); |
| 2750 | if (pseudoElement.startsWith('::view-transition-old')) { |
| 2751 | const groupName = pseudoElement.slice(21); |
| 2752 | if (!foundGroups.has(groupName) && !foundNews.has(groupName)) { |
| 2753 | foundGroups.add(groupName); |
| 2754 | // We haven't seen any group animation with this name. Since the old |
| 2755 | // state was outside the viewport we need to put it back. Since we |
| 2756 | // can't programmatically target the element itself, we use an |
| 2757 | // animation to adjust it. |
| 2758 | // This usually happens for exit animations where the element has |
| 2759 | // the old position. |
| 2760 | // If we also have a "new" state then we skip this because it means |
| 2761 | // someone manually disabled the auto-generated animation. We need to |
| 2762 | // treat the old state as having the position of the "new" state which |
| 2763 | // will happen by default. |
| 2764 | const pseudoElementName = '::view-transition-group' + groupName; |
| 2765 | animateGesture( |
| 2766 | [{}, {}], |
| 2767 | // $FlowFixMe[incompatible-type]: Always documentElement atm. |
| 2768 | effect.target, |
| 2769 | pseudoElementName, |
| 2770 | timeline, |
| 2771 | viewTransitionAnimations, |
| 2772 | customTimelineCleanup, |
| 2773 | rangeStart, |
| 2774 | rangeEnd, |
| 2775 | false, |
| 2776 | true, // We let the helper apply the translate |
| 2777 | ); |
| 2778 | } |
| 2779 | } |
| 2780 | } |
| 2781 | } |
| 2782 | // View Transitions with ScrollTimeline has a quirk where they end if the |
| 2783 | // ScrollTimeline ever reaches 100% but that doesn't mean we're done because |
| 2784 | // you can swipe back again. We can prevent this by adding a paused Animation |
| 2785 | // that never stops. This seems to keep all running Animations alive until |
| 2786 | // we explicitly abort (or something forces the View Transition to cancel). |
| 2787 | // $FlowFixMe[incompatible-call] |
| 2788 | // $FlowFixMe[incompatible-type] |
| 2789 | const blockingAnim = documentElement.animate([{}, {}], { |
| 2790 | pseudoElement: '::view-transition', |
| 2791 | duration: 1, |
| 2792 | }); |
| 2793 | blockingAnim.pause(); |
| 2794 | viewTransitionAnimations.push(blockingAnim); |
| 2795 | animateCallback(); |
| 2796 | }; |
| 2797 | // In Chrome, "new" animations are not ready in the ready callback. We have to wait |
| 2798 | // until requestAnimationFrame before we can observe them through getAnimations(). |
| 2799 | // However, in Safari, that would cause a flicker because we're applying them late. |
| 2800 | // TODO: Think of a feature detection for this instead. |
| 2801 | const readyForAnimations = |
| 2802 | navigator.userAgent.indexOf('Chrome') !== -1 |
| 2803 | ? () => requestAnimationFrame(readyCallback) |
| 2804 | : readyCallback; |
| 2805 | const handleError = (error: mixed) => { |
| 2806 | // $FlowFixMe[prop-missing] |
| 2807 | if (ownerDocument.__reactViewTransition === transition) { |
| 2808 | // $FlowFixMe[prop-missing] |
| 2809 | ownerDocument.__reactViewTransition = null; |
| 2810 | } |
| 2811 | try { |
| 2812 | error = customizeViewTransitionError(error, true); |
| 2813 | if (error !== null) { |
| 2814 | errorCallback(error); |
| 2815 | } |
| 2816 | } finally { |
| 2817 | // Continue the reset of the work. |
| 2818 | // If the error happened in the snapshot phase before the update callback |
| 2819 | // was invoked, then we need to first finish the mutation and layout phases. |
| 2820 | // If they're already invoked it's still safe to call them due the status check. |
| 2821 | mutationCallback(); |
| 2822 | // Skip readyCallback() and go straight to animateCallbck() since we're not animating. |
| 2823 | // animateCallback() is still required to restore states. |
| 2824 | animateCallback(); |
| 2825 | if (enableProfilerTimer) { |
| 2826 | finishedAnimation(); |
| 2827 | } |
| 2828 | } |
| 2829 | }; |
| 2830 | transition.ready.then(readyForAnimations, handleError); |
| 2831 | transition.finished.finally(() => { |
| 2832 | for (let i = 0; i < viewTransitionAnimations.length; i++) { |
| 2833 | // In Safari, we need to manually cancel all manually started animations |
| 2834 | // or it'll block or interfer with future transitions. |
| 2835 | // We can't use getAnimations() due to #35336 so we collect them in an array. |
| 2836 | viewTransitionAnimations[i].cancel(); |
| 2837 | } |
| 2838 | for (let i = 0; i < customTimelineCleanup.length; i++) { |
| 2839 | const cleanup = customTimelineCleanup[i]; |
| 2840 | cleanup(); |
| 2841 | } |
| 2842 | // $FlowFixMe[prop-missing] |
| 2843 | if (ownerDocument.__reactViewTransition === transition) { |
| 2844 | // $FlowFixMe[prop-missing] |
| 2845 | ownerDocument.__reactViewTransition = null; |
| 2846 | } |
| 2847 | if (enableProfilerTimer) { |
| 2848 | // Signal that the Transition was unable to continue. We do that here |
| 2849 | // instead of when we stop the running View Transition to ensure that |
| 2850 | // we cover cases when something else stops it early. |
| 2851 | finishedAnimation(); |
| 2852 | } |
| 2853 | }); |
| 2854 | return transition; |
| 2855 | } catch (x) { |
| 2856 | // We use the error as feature detection. |
| 2857 | // The only thing that should throw is if startViewTransition is missing |
| 2858 | // or if it doesn't accept the object form. Other errors are async. |
| 2859 | // I.e. it's before the View Transitions v2 spec. We only support View |
| 2860 | // Transitions v2 otherwise we fallback to not animating to ensure that |
| 2861 | // we're not animating with the wrong animation mapped. |
| 2862 | // Run through the sequence to put state back into a consistent state. |
| 2863 | mutationCallback(); |
| 2864 | animateCallback(); |
| 2865 | if (enableProfilerTimer) { |
| 2866 | finishedAnimation(); |
| 2867 | } |
| 2868 | return null; |
| 2869 | } |
| 2870 | } |
| 2871 | |
| 2872 | export function stopViewTransition(transition: RunningViewTransition) { |
| 2873 | transition.skipTransition(); |
| 2874 | } |
| 2875 | |
| 2876 | export function addViewTransitionFinishedListener( |
| 2877 | transition: RunningViewTransition, |
| 2878 | callback: () => void, |
| 2879 | ) { |
| 2880 | transition.finished.finally(callback); |
| 2881 | } |
| 2882 | |
| 2883 | interface ViewTransitionPseudoElementType extends mixin$Animatable { |
| 2884 | _scope: HTMLElement; |
| 2885 | _selector: string; |
| 2886 | getComputedStyle(): CSSStyleDeclaration; |
| 2887 | } |
| 2888 | |
| 2889 | function ViewTransitionPseudoElement( |
| 2890 | this: ViewTransitionPseudoElementType, |
| 2891 | pseudo: string, |
| 2892 | name: string, |
| 2893 | ) { |
| 2894 | // TODO: Get the owner document from the root container. |
| 2895 | this._scope = document.documentElement as any; |
| 2896 | this._selector = '::view-transition-' + pseudo + '(' + name + ')'; |
| 2897 | } |
| 2898 | // $FlowFixMe[prop-missing] |
| 2899 | ViewTransitionPseudoElement.prototype.animate = function ( |
| 2900 | this: ViewTransitionPseudoElementType, |
| 2901 | keyframes: Keyframe[] | PropertyIndexedKeyframes | null, |
| 2902 | options?: number | KeyframeAnimationOptions, |
| 2903 | ): Animation { |
| 2904 | const opts: any = |
| 2905 | typeof options === 'number' |
| 2906 | ? { |
| 2907 | duration: options, |
| 2908 | } |
| 2909 | : Object.assign( |
| 2910 | // $FlowFixMe[prop-missing] |
| 2911 | // $FlowFixMe[incompatible-type] |
| 2912 | {} as KeyframeAnimationOptions, |
| 2913 | options, |
| 2914 | ); |
| 2915 | opts.pseudoElement = this._selector; |
| 2916 | // TODO: Handle multiple child instances. |
| 2917 | return this._scope.animate(keyframes, opts); |
| 2918 | }; |
| 2919 | // $FlowFixMe[prop-missing] |
| 2920 | ViewTransitionPseudoElement.prototype.getAnimations = function ( |
| 2921 | this: ViewTransitionPseudoElementType, |
| 2922 | options?: GetAnimationsOptions, |
| 2923 | ): Animation[] { |
| 2924 | const scope = this._scope; |
| 2925 | const selector = this._selector; |
| 2926 | const animations = scope.getAnimations( |
| 2927 | // $FlowFixMe[prop-missing] |
| 2928 | // $FlowFixMe[incompatible-type] |
| 2929 | {subtree: true}, |
| 2930 | ); |
| 2931 | const result = []; |
| 2932 | for (let i = 0; i < animations.length; i++) { |
| 2933 | const effect: null | { |
| 2934 | target?: Element, |
| 2935 | pseudoElement?: string, |
| 2936 | ... |
| 2937 | } = animations[i].effect as any; |
| 2938 | // TODO: Handle multiple child instances. |
| 2939 | if ( |
| 2940 | effect !== null && |
| 2941 | effect.target === scope && |
| 2942 | effect.pseudoElement === selector |
| 2943 | ) { |
| 2944 | result.push(animations[i]); |
| 2945 | } |
| 2946 | } |
| 2947 | return result; |
| 2948 | }; |
| 2949 | // $FlowFixMe[prop-missing] |
| 2950 | ViewTransitionPseudoElement.prototype.getComputedStyle = function ( |
| 2951 | this: ViewTransitionPseudoElementType, |
| 2952 | ): CSSStyleDeclaration { |
| 2953 | const scope = this._scope; |
| 2954 | const selector = this._selector; |
| 2955 | return getComputedStyle(scope, selector); |
| 2956 | }; |
| 2957 | |
| 2958 | export function createViewTransitionInstance( |
| 2959 | name: string, |
| 2960 | ): ViewTransitionInstance { |
| 2961 | return { |
| 2962 | name: name, |
| 2963 | group: new (ViewTransitionPseudoElement as any)('group', name), |
| 2964 | imagePair: new (ViewTransitionPseudoElement as any)('image-pair', name), |
| 2965 | old: new (ViewTransitionPseudoElement as any)('old', name), |
| 2966 | new: new (ViewTransitionPseudoElement as any)('new', name), |
| 2967 | }; |
| 2968 | } |
| 2969 | |
| 2970 | interface CustomTimeline { |
| 2971 | currentTime: number; |
| 2972 | animate(animation: Animation): void | (() => void); |
| 2973 | } |
| 2974 | |
| 2975 | export type GestureTimeline = AnimationTimeline | CustomTimeline; |
| 2976 | |
| 2977 | export function getCurrentGestureOffset(timeline: GestureTimeline): number { |
| 2978 | const time = timeline.currentTime; |
| 2979 | if (time === null) { |
| 2980 | throw new Error( |
| 2981 | 'Cannot start a gesture with a disconnected AnimationTimeline.', |
| 2982 | ); |
| 2983 | } |
| 2984 | return typeof time === 'number' ? time : time.value; |
| 2985 | } |
| 2986 | |
| 2987 | type StoredEventListener = { |
| 2988 | type: string, |
| 2989 | listener: EventListener, |
| 2990 | optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, |
| 2991 | // When once:true, a wrapper that removes the fragment listener after the |
| 2992 | // first fire. Otherwise the same as listener. |
| 2993 | attachedListener: EventListener, |
| 2994 | }; |
| 2995 | |
| 2996 | export type FragmentInstanceType = { |
| 2997 | _fragmentFiber: Fiber, |
| 2998 | _eventListeners: null | Array<StoredEventListener>, |
| 2999 | _observers: null | Set<IntersectionObserver | ResizeObserver>, |
| 3000 | addEventListener( |
| 3001 | type: string, |
| 3002 | listener: EventListener, |
| 3003 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3004 | ): void, |
| 3005 | removeEventListener( |
| 3006 | type: string, |
| 3007 | listener: EventListener, |
| 3008 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3009 | ): void, |
| 3010 | dispatchEvent(event: Event): boolean, |
| 3011 | focus(focusOptions?: FocusOptions): void, |
| 3012 | focusLast(focusOptions?: FocusOptions): void, |
| 3013 | blur(): void, |
| 3014 | observeUsing(observer: IntersectionObserver | ResizeObserver): void, |
| 3015 | unobserveUsing(observer: IntersectionObserver | ResizeObserver): void, |
| 3016 | getClientRects(): Array<DOMRect>, |
| 3017 | getRootNode(getRootNodeOptions?: { |
| 3018 | composed: boolean, |
| 3019 | }): Document | ShadowRoot | FragmentInstanceType, |
| 3020 | compareDocumentPosition(otherNode: Instance): number, |
| 3021 | scrollIntoView(alignToTop?: boolean): void, |
| 3022 | }; |
| 3023 | |
| 3024 | function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { |
| 3025 | this._fragmentFiber = fragmentFiber; |
| 3026 | this._eventListeners = null; |
| 3027 | this._observers = null; |
| 3028 | } |
| 3029 | |
| 3030 | // $FlowFixMe[prop-missing] |
| 3031 | FragmentInstance.prototype.addEventListener = function ( |
| 3032 | this: FragmentInstanceType, |
| 3033 | type: string, |
| 3034 | listener: EventListener, |
| 3035 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3036 | ): void { |
| 3037 | if (this._eventListeners === null) { |
| 3038 | this._eventListeners = []; |
| 3039 | } |
| 3040 | |
| 3041 | const listeners = this._eventListeners; |
| 3042 | // Element.addEventListener will only apply uniquely new event listeners by default. Since we |
| 3043 | // need to collect the listeners to apply to appended children, we track them ourselves and use |
| 3044 | // custom equality check for the options. |
| 3045 | const isNewEventListener = |
| 3046 | indexOfEventListener(listeners, type, listener, optionsOrUseCapture) === -1; |
| 3047 | if (isNewEventListener) { |
| 3048 | const fragmentInstance = this; |
| 3049 | let attachedListener = listener; |
| 3050 | if (isOnceOption(optionsOrUseCapture)) { |
| 3051 | // once is fragment-scoped: the first fire on any child removes this |
| 3052 | // listener from the fragment and every host child. |
| 3053 | attachedListener = function (this: EventTarget, event: Event) { |
| 3054 | fragmentInstance.removeEventListener( |
| 3055 | type, |
| 3056 | listener, |
| 3057 | optionsOrUseCapture, |
| 3058 | ); |
| 3059 | if (typeof listener === 'function') { |
| 3060 | listener.call(this, event); |
| 3061 | } else { |
| 3062 | listener.handleEvent(event); |
| 3063 | } |
| 3064 | }; |
| 3065 | } |
| 3066 | const attachOptions = getAttachOptions(optionsOrUseCapture); |
| 3067 | listeners.push({ |
| 3068 | type, |
| 3069 | listener, |
| 3070 | optionsOrUseCapture, |
| 3071 | attachedListener, |
| 3072 | }); |
| 3073 | traverseFragmentInstancesAndTextInstances( |
| 3074 | this._fragmentFiber, |
| 3075 | addEventListenerToChild, |
| 3076 | type, |
| 3077 | attachedListener, |
| 3078 | attachOptions, |
| 3079 | ); |
| 3080 | } |
| 3081 | this._eventListeners = listeners; |
| 3082 | }; |
| 3083 | function addEventListenerToChild( |
| 3084 | child: Fiber, |
| 3085 | type: string, |
| 3086 | listener: EventListener, |
| 3087 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3088 | ): boolean { |
| 3089 | const instance = getInstanceFromHostFiber<Instance | TextInstance>(child); |
| 3090 | instance.addEventListener(type, listener, optionsOrUseCapture); |
| 3091 | return false; |
| 3092 | } |
| 3093 | // $FlowFixMe[prop-missing] |
| 3094 | FragmentInstance.prototype.removeEventListener = function ( |
| 3095 | this: FragmentInstanceType, |
| 3096 | type: string, |
| 3097 | listener: EventListener, |
| 3098 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3099 | ): void { |
| 3100 | const listeners = this._eventListeners; |
| 3101 | if (listeners === null) { |
| 3102 | return; |
| 3103 | } |
| 3104 | const index = indexOfEventListener( |
| 3105 | listeners, |
| 3106 | type, |
| 3107 | listener, |
| 3108 | optionsOrUseCapture, |
| 3109 | ); |
| 3110 | if (index === -1) { |
| 3111 | return; |
| 3112 | } |
| 3113 | const {attachedListener, optionsOrUseCapture: storedOptions} = |
| 3114 | listeners[index]; |
| 3115 | const attachOptions = getAttachOptions(storedOptions); |
| 3116 | traverseFragmentInstancesAndTextInstances( |
| 3117 | this._fragmentFiber, |
| 3118 | removeEventListenerFromChild, |
| 3119 | type, |
| 3120 | attachedListener, |
| 3121 | attachOptions, |
| 3122 | ); |
| 3123 | listeners.splice(index, 1); |
| 3124 | }; |
| 3125 | function removeEventListenerFromChild( |
| 3126 | child: Fiber, |
| 3127 | type: string, |
| 3128 | listener: EventListener, |
| 3129 | optionsOrUseCapture?: EventListenerOptionsOrUseCapture, |
| 3130 | ): boolean { |
| 3131 | const instance = getInstanceFromHostFiber<Instance | TextInstance>(child); |
| 3132 | instance.removeEventListener(type, listener, optionsOrUseCapture); |
| 3133 | return false; |
| 3134 | } |
| 3135 | function isOnceOption(opts: ?EventListenerOptionsOrUseCapture): boolean { |
| 3136 | return opts != null && typeof opts !== 'boolean' && opts.once === true; |
| 3137 | } |
| 3138 | function getAttachOptions( |
| 3139 | opts: void | EventListenerOptionsOrUseCapture, |
| 3140 | ): void | EventListenerOptionsOrUseCapture { |
| 3141 | // Strip once when attaching to host children; Fragment owns once semantics. |
| 3142 | if (opts == null || typeof opts === 'boolean' || opts.once !== true) { |
| 3143 | return opts; |
| 3144 | } |
| 3145 | return { |
| 3146 | capture: opts.capture, |
| 3147 | passive: opts.passive, |
| 3148 | signal: opts.signal, |
| 3149 | }; |
| 3150 | } |
| 3151 | function normalizeListenerOptions( |
| 3152 | opts: ?EventListenerOptionsOrUseCapture, |
| 3153 | ): string { |
| 3154 | if (opts == null) { |
| 3155 | return 'c=0'; |
| 3156 | } |
| 3157 | |
| 3158 | if (typeof opts === 'boolean') { |
| 3159 | return `c=${opts ? '1' : '0'}`; |
| 3160 | } |
| 3161 | |
| 3162 | return `c=${opts.capture ? '1' : '0'}`; |
| 3163 | } |
| 3164 | function indexOfEventListener( |
| 3165 | eventListeners: Array<StoredEventListener>, |
| 3166 | type: string, |
| 3167 | listener: EventListener, |
| 3168 | optionsOrUseCapture: void | EventListenerOptionsOrUseCapture, |
| 3169 | ): number { |
| 3170 | if (eventListeners.length === 0) { |
| 3171 | return -1; |
| 3172 | } |
| 3173 | const normalizedOptions = normalizeListenerOptions(optionsOrUseCapture); |
| 3174 | for (let i = 0; i < eventListeners.length; i++) { |
| 3175 | const item = eventListeners[i]; |
| 3176 | if ( |
| 3177 | item.type === type && |
| 3178 | item.listener === listener && |
| 3179 | normalizeListenerOptions(item.optionsOrUseCapture) === normalizedOptions |
| 3180 | ) { |
| 3181 | return i; |
| 3182 | } |
| 3183 | } |
| 3184 | return -1; |
| 3185 | } |
| 3186 | // $FlowFixMe[prop-missing] |
| 3187 | FragmentInstance.prototype.dispatchEvent = function ( |
| 3188 | this: FragmentInstanceType, |
| 3189 | event: Event, |
| 3190 | ): boolean { |
| 3191 | const parentHostFiber = getFragmentParentInstanceOrContainerFiber( |
| 3192 | this._fragmentFiber, |
| 3193 | ); |
| 3194 | if (parentHostFiber === null) { |
| 3195 | return true; |
| 3196 | } |
| 3197 | const parentHostInstance = getInstanceFromHostFiber<Instance | Container>( |
| 3198 | parentHostFiber, |
| 3199 | ); |
| 3200 | const eventListeners = this._eventListeners; |
| 3201 | if ( |
| 3202 | (eventListeners !== null && eventListeners.length > 0) || |
| 3203 | !event.bubbles |
| 3204 | ) { |
| 3205 | // The temporary node stands in for the fragment's position so that its own |
| 3206 | // listeners fire before the event propagates to the parent. A Document can |
| 3207 | // only hold comments and processing instructions alongside its |
| 3208 | // documentElement, so a Text node would be an invalid child there. |
| 3209 | const temp = |
| 3210 | parentHostInstance.nodeType === DOCUMENT_NODE |
| 3211 | ? (parentHostInstance as any as Document).createComment('') |
| 3212 | : document.createTextNode(''); |
| 3213 | if (eventListeners) { |
| 3214 | for (let i = 0; i < eventListeners.length; i++) { |
| 3215 | const {type, attachedListener, optionsOrUseCapture} = eventListeners[i]; |
| 3216 | temp.addEventListener( |
| 3217 | type, |
| 3218 | attachedListener, |
| 3219 | getAttachOptions(optionsOrUseCapture), |
| 3220 | ); |
| 3221 | } |
| 3222 | } |
| 3223 | parentHostInstance.appendChild(temp); |
| 3224 | const cancelable = temp.dispatchEvent(event); |
| 3225 | if (eventListeners) { |
| 3226 | for (let i = 0; i < eventListeners.length; i++) { |
| 3227 | const {type, attachedListener, optionsOrUseCapture} = eventListeners[i]; |
| 3228 | temp.removeEventListener( |
| 3229 | type, |
| 3230 | attachedListener, |
| 3231 | getAttachOptions(optionsOrUseCapture), |
| 3232 | ); |
| 3233 | } |
| 3234 | } |
| 3235 | parentHostInstance.removeChild(temp); |
| 3236 | return cancelable; |
| 3237 | } else { |
| 3238 | return parentHostInstance.dispatchEvent(event); |
| 3239 | } |
| 3240 | }; |
| 3241 | // $FlowFixMe[prop-missing] |
| 3242 | FragmentInstance.prototype.focus = function ( |
| 3243 | this: FragmentInstanceType, |
| 3244 | focusOptions?: FocusOptions, |
| 3245 | ): void { |
| 3246 | traverseFragmentInstancesAndTextInstancesDeeply( |
| 3247 | this._fragmentFiber, |
| 3248 | setFocusOnFiberIfFocusable, |
| 3249 | focusOptions, |
| 3250 | ); |
| 3251 | }; |
| 3252 | function setFocusOnFiberIfFocusable( |
| 3253 | fiber: Fiber, |
| 3254 | focusOptions?: FocusOptions, |
| 3255 | ): boolean { |
| 3256 | if (enableFragmentRefsTextNodes) { |
| 3257 | // Skip text nodes - they are not focusable |
| 3258 | if (fiber.tag === HostText) { |
| 3259 | return false; |
| 3260 | } |
| 3261 | } |
| 3262 | const instance = getInstanceFromHostFiber<Instance>(fiber); |
| 3263 | return setFocusIfFocusable(instance, focusOptions); |
| 3264 | } |
| 3265 | // $FlowFixMe[prop-missing] |
| 3266 | FragmentInstance.prototype.focusLast = function ( |
| 3267 | this: FragmentInstanceType, |
| 3268 | focusOptions?: FocusOptions, |
| 3269 | ): void { |
| 3270 | const children: Array<Fiber> = []; |
| 3271 | traverseFragmentInstancesAndTextInstancesDeeply( |
| 3272 | this._fragmentFiber, |
| 3273 | collectChildren, |
| 3274 | children, |
| 3275 | ); |
| 3276 | for (let i = children.length - 1; i >= 0; i--) { |
| 3277 | const child = children[i]; |
| 3278 | if (setFocusOnFiberIfFocusable(child, focusOptions)) { |
| 3279 | break; |
| 3280 | } |
| 3281 | } |
| 3282 | }; |
| 3283 | function collectChildren(child: Fiber, collection: Array<Fiber>): boolean { |
| 3284 | collection.push(child); |
| 3285 | return false; |
| 3286 | } |
| 3287 | // $FlowFixMe[prop-missing] |
| 3288 | FragmentInstance.prototype.blur = function (this: FragmentInstanceType): void { |
| 3289 | const parentHostFiber = getFragmentParentInstanceOrContainerFiber( |
| 3290 | this._fragmentFiber, |
| 3291 | ); |
| 3292 | if (parentHostFiber === null) { |
| 3293 | return; |
| 3294 | } |
| 3295 | const parentInstanceOrContainer = getInstanceFromHostFiber< |
| 3296 | Instance | Container, |
| 3297 | >(parentHostFiber); |
| 3298 | // Instance is included in the Container type for DOM. |
| 3299 | const ownerDocument = getOwnerDocumentFromRootContainer( |
| 3300 | parentInstanceOrContainer, |
| 3301 | ); |
| 3302 | const activeElement = ownerDocument.activeElement; |
| 3303 | if (activeElement === null) { |
| 3304 | return; |
| 3305 | } |
| 3306 | traverseFragmentInstancesAndTextInstances( |
| 3307 | this._fragmentFiber, |
| 3308 | blurActiveElementWithinFragment, |
| 3309 | activeElement, |
| 3310 | ); |
| 3311 | }; |
| 3312 | function blurActiveElementWithinFragment( |
| 3313 | child: Fiber, |
| 3314 | activeElement: Element, |
| 3315 | ): boolean { |
| 3316 | // Skip text nodes - they can't be focused |
| 3317 | if (enableFragmentRefsTextNodes && child.tag === HostText) { |
| 3318 | return false; |
| 3319 | } |
| 3320 | const instance = getInstanceFromHostFiber<Instance>(child); |
| 3321 | if (instance === activeElement || instance.contains(activeElement)) { |
| 3322 | // $FlowFixMe[prop-missing] |
| 3323 | activeElement.blur(); |
| 3324 | return true; |
| 3325 | } |
| 3326 | return false; |
| 3327 | } |
| 3328 | // $FlowFixMe[prop-missing] |
| 3329 | FragmentInstance.prototype.observeUsing = function ( |
| 3330 | this: FragmentInstanceType, |
| 3331 | observer: IntersectionObserver | ResizeObserver, |
| 3332 | ): void { |
| 3333 | if (__DEV__) { |
| 3334 | if (enableFragmentRefsTextNodes) { |
| 3335 | let hasText = false; |
| 3336 | let hasElement = false; |
| 3337 | traverseFragmentInstancesAndTextInstances( |
| 3338 | this._fragmentFiber, |
| 3339 | (child: Fiber) => { |
| 3340 | if (child.tag === HostText) { |
| 3341 | hasText = true; |
| 3342 | } else { |
| 3343 | // Stop traversal, found element |
| 3344 | hasElement = true; |
| 3345 | return true; |
| 3346 | } |
| 3347 | return false; |
| 3348 | }, |
| 3349 | ); |
| 3350 | if (hasText && !hasElement) { |
| 3351 | console.error( |
| 3352 | 'observeUsing() was called on a FragmentInstance with only text children. ' + |
| 3353 | 'Observers do not work on text nodes.', |
| 3354 | ); |
| 3355 | } |
| 3356 | } |
| 3357 | } |
| 3358 | if (this._observers === null) { |
| 3359 | this._observers = new Set(); |
| 3360 | } |
| 3361 | this._observers.add(observer); |
| 3362 | traverseFragmentInstancesAndTextInstances( |
| 3363 | this._fragmentFiber, |
| 3364 | observeChild, |
| 3365 | observer, |
| 3366 | ); |
| 3367 | }; |
| 3368 | function observeChild( |
| 3369 | child: Fiber, |
| 3370 | observer: IntersectionObserver | ResizeObserver, |
| 3371 | ) { |
| 3372 | if (enableFragmentRefsTextNodes) { |
| 3373 | // Skip text nodes - observers don't work on them |
| 3374 | if (child.tag === HostText) { |
| 3375 | return false; |
| 3376 | } |
| 3377 | } |
| 3378 | const instance = getInstanceFromHostFiber<Instance>(child); |
| 3379 | observer.observe(instance); |
| 3380 | return false; |
| 3381 | } |
| 3382 | // $FlowFixMe[prop-missing] |
| 3383 | FragmentInstance.prototype.unobserveUsing = function ( |
| 3384 | this: FragmentInstanceType, |
| 3385 | observer: IntersectionObserver | ResizeObserver, |
| 3386 | ): void { |
| 3387 | const observers = this._observers; |
| 3388 | if (observers === null || !observers.has(observer)) { |
| 3389 | if (__DEV__) { |
| 3390 | console.error( |
| 3391 | 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' + |
| 3392 | 'instance. First attach the observer with observeUsing()', |
| 3393 | ); |
| 3394 | } |
| 3395 | } else { |
| 3396 | observers.delete(observer); |
| 3397 | traverseFragmentInstancesAndTextInstances( |
| 3398 | this._fragmentFiber, |
| 3399 | unobserveChild, |
| 3400 | observer, |
| 3401 | ); |
| 3402 | } |
| 3403 | }; |
| 3404 | function unobserveChild( |
| 3405 | child: Fiber, |
| 3406 | observer: IntersectionObserver | ResizeObserver, |
| 3407 | ) { |
| 3408 | if (enableFragmentRefsTextNodes) { |
| 3409 | // Skip text nodes - they were never observed |
| 3410 | if (child.tag === HostText) { |
| 3411 | return false; |
| 3412 | } |
| 3413 | } |
| 3414 | const instance = getInstanceFromHostFiber<Instance>(child); |
| 3415 | observer.unobserve(instance); |
| 3416 | return false; |
| 3417 | } |
| 3418 | // $FlowFixMe[prop-missing] |
| 3419 | FragmentInstance.prototype.getClientRects = function ( |
| 3420 | this: FragmentInstanceType, |
| 3421 | ): Array<DOMRect> { |
| 3422 | const rects: Array<DOMRect> = []; |
| 3423 | traverseFragmentInstancesAndTextInstances( |
| 3424 | this._fragmentFiber, |
| 3425 | collectClientRects, |
| 3426 | rects, |
| 3427 | ); |
| 3428 | return rects; |
| 3429 | }; |
| 3430 | function collectClientRects(child: Fiber, rects: Array<DOMRect>): boolean { |
| 3431 | if (enableFragmentRefsTextNodes && child.tag === HostText) { |
| 3432 | const textNode: Text = child.stateNode; |
| 3433 | const range = textNode.ownerDocument.createRange(); |
| 3434 | range.selectNodeContents(textNode); |
| 3435 | // $FlowFixMe[method-unbinding] |
| 3436 | rects.push.apply(rects, range.getClientRects()); |
| 3437 | } else { |
| 3438 | const instance = getInstanceFromHostFiber<Instance>(child); |
| 3439 | // $FlowFixMe[method-unbinding] |
| 3440 | rects.push.apply(rects, instance.getClientRects()); |
| 3441 | } |
| 3442 | return false; |
| 3443 | } |
| 3444 | // $FlowFixMe[prop-missing] |
| 3445 | FragmentInstance.prototype.getRootNode = function ( |
| 3446 | this: FragmentInstanceType, |
| 3447 | getRootNodeOptions?: {composed: boolean}, |
| 3448 | ): Document | ShadowRoot | FragmentInstanceType { |
| 3449 | const parentHostFiber = getFragmentParentInstanceOrContainerFiber( |
| 3450 | this._fragmentFiber, |
| 3451 | ); |
| 3452 | if (parentHostFiber === null) { |
| 3453 | return this; |
| 3454 | } |
| 3455 | const parentHostInstance = getInstanceFromHostFiber<Instance | Container>( |
| 3456 | parentHostFiber, |
| 3457 | ); |
| 3458 | const rootNode = |
| 3459 | // $FlowFixMe[incompatible-type] Flow expects Node |
| 3460 | parentHostInstance.getRootNode(getRootNodeOptions) as Document | ShadowRoot; |
| 3461 | return rootNode; |
| 3462 | }; |
| 3463 | // $FlowFixMe[prop-missing] |
| 3464 | FragmentInstance.prototype.compareDocumentPosition = function ( |
| 3465 | this: FragmentInstanceType, |
| 3466 | otherNode: Instance, |
| 3467 | ): number { |
| 3468 | const parentHostFiber = getFragmentParentInstanceOrContainerFiber( |
| 3469 | this._fragmentFiber, |
| 3470 | ); |
| 3471 | if (parentHostFiber === null) { |
| 3472 | return Node.DOCUMENT_POSITION_DISCONNECTED; |
| 3473 | } |
| 3474 | const children: Array<Fiber> = []; |
| 3475 | traverseFragmentInstancesAndTextInstances( |
| 3476 | this._fragmentFiber, |
| 3477 | collectChildren, |
| 3478 | children, |
| 3479 | ); |
| 3480 | const parentHostInstance = getInstanceFromHostFiber<Instance | Container>( |
| 3481 | parentHostFiber, |
| 3482 | ); |
| 3483 | |
| 3484 | if (children.length === 0) { |
| 3485 | // Match non-empty CDP: when portaled, position against the portal |
| 3486 | // container rather than the React host parent. |
| 3487 | let emptyParentHostInstance = parentHostInstance; |
| 3488 | if (fiberIsPortaledIntoHost(this._fragmentFiber)) { |
| 3489 | const portalContainer = getFragmentPortalContainerInfo( |
| 3490 | this._fragmentFiber, |
| 3491 | ); |
| 3492 | if (portalContainer != null) { |
| 3493 | emptyParentHostInstance = portalContainer; |
| 3494 | } |
| 3495 | } |
| 3496 | return compareDocumentPositionForEmptyFragment( |
| 3497 | this._fragmentFiber, |
| 3498 | emptyParentHostInstance, |
| 3499 | otherNode, |
| 3500 | getInstanceFromHostFiber, |
| 3501 | ); |
| 3502 | } |
| 3503 | |
| 3504 | const firstNode = getInstanceFromHostFiber<Instance | TextInstance>( |
| 3505 | children[0], |
| 3506 | ); |
| 3507 | const lastNode = getInstanceFromHostFiber<Instance | TextInstance>( |
| 3508 | children[children.length - 1], |
| 3509 | ); |
| 3510 | |
| 3511 | // If the fragment has been portaled into another host instance, we need to |
| 3512 | // our best guess is to use the parent of the child instance, rather than |
| 3513 | // the fiber tree host parent. |
| 3514 | const parentHostInstanceFromDOM = fiberIsPortaledIntoHost(this._fragmentFiber) |
| 3515 | ? (firstNode.parentElement as ?Instance) |
| 3516 | : parentHostInstance; |
| 3517 | |
| 3518 | if (parentHostInstanceFromDOM == null) { |
| 3519 | return Node.DOCUMENT_POSITION_DISCONNECTED; |
| 3520 | } |
| 3521 | |
| 3522 | // Check if first and last node are actually in the expected document position |
| 3523 | // before relying on them as source of truth for other contained nodes |
| 3524 | const firstNodeIsContained = |
| 3525 | parentHostInstanceFromDOM.compareDocumentPosition(firstNode) & |
| 3526 | Node.DOCUMENT_POSITION_CONTAINED_BY; |
| 3527 | const lastNodeIsContained = |
| 3528 | parentHostInstanceFromDOM.compareDocumentPosition(lastNode) & |
| 3529 | Node.DOCUMENT_POSITION_CONTAINED_BY; |
| 3530 | const firstResult = firstNode.compareDocumentPosition(otherNode); |
| 3531 | const lastResult = lastNode.compareDocumentPosition(otherNode); |
| 3532 | |
| 3533 | const otherNodeIsFirstOrLastChild = |
| 3534 | (firstNodeIsContained && firstNode === otherNode) || |
| 3535 | (lastNodeIsContained && lastNode === otherNode); |
| 3536 | const otherNodeIsFirstOrLastChildDisconnected = |
| 3537 | (!firstNodeIsContained && firstNode === otherNode) || |
| 3538 | (!lastNodeIsContained && lastNode === otherNode); |
| 3539 | const otherNodeIsWithinFirstOrLastChild = |
| 3540 | firstResult & Node.DOCUMENT_POSITION_CONTAINED_BY || |
| 3541 | lastResult & Node.DOCUMENT_POSITION_CONTAINED_BY; |
| 3542 | const otherNodeIsBetweenFirstAndLastChildren = |
| 3543 | firstNodeIsContained && |
| 3544 | lastNodeIsContained && |
| 3545 | firstResult & Node.DOCUMENT_POSITION_FOLLOWING && |
| 3546 | lastResult & Node.DOCUMENT_POSITION_PRECEDING; |
| 3547 | |
| 3548 | let result = Node.DOCUMENT_POSITION_DISCONNECTED; |
| 3549 | if ( |
| 3550 | otherNodeIsFirstOrLastChild || |
| 3551 | otherNodeIsWithinFirstOrLastChild || |
| 3552 | otherNodeIsBetweenFirstAndLastChildren |
| 3553 | ) { |
| 3554 | result = Node.DOCUMENT_POSITION_CONTAINED_BY; |
| 3555 | } else if (otherNodeIsFirstOrLastChildDisconnected) { |
| 3556 | // otherNode has been portaled into another container |
| 3557 | result = Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC; |
| 3558 | } else { |
| 3559 | result = firstResult; |
| 3560 | } |
| 3561 | |
| 3562 | if ( |
| 3563 | result & Node.DOCUMENT_POSITION_DISCONNECTED || |
| 3564 | result & Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC |
| 3565 | ) { |
| 3566 | return result; |
| 3567 | } |
| 3568 | |
| 3569 | // Now that we have the result from the DOM API, we double check it matches |
| 3570 | // the state of the React tree. If it doesn't, we have a case of portaled or |
| 3571 | // otherwise injected elements and we return DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC. |
| 3572 | const documentPositionMatchesFiberPosition = |
| 3573 | validateDocumentPositionWithFiberTree( |
| 3574 | result, |
| 3575 | this._fragmentFiber, |
| 3576 | children[0], |
| 3577 | children[children.length - 1], |
| 3578 | otherNode, |
| 3579 | ); |
| 3580 | if (documentPositionMatchesFiberPosition) { |
| 3581 | return result; |
| 3582 | } |
| 3583 | return Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC; |
| 3584 | }; |
| 3585 | |
| 3586 | function validateDocumentPositionWithFiberTree( |
| 3587 | documentPosition: number, |
| 3588 | fragmentFiber: Fiber, |
| 3589 | precedingBoundaryFiber: Fiber, |
| 3590 | followingBoundaryFiber: Fiber, |
| 3591 | otherNode: Instance, |
| 3592 | ): boolean { |
| 3593 | const otherFiber = getClosestInstanceFromNode(otherNode); |
| 3594 | if (documentPosition & Node.DOCUMENT_POSITION_CONTAINED_BY) { |
| 3595 | return ( |
| 3596 | !!otherFiber && isFiberContainedByFragment(otherFiber, fragmentFiber) |
| 3597 | ); |
| 3598 | } |
| 3599 | if (documentPosition & Node.DOCUMENT_POSITION_CONTAINS) { |
| 3600 | if (otherFiber === null) { |
| 3601 | // otherFiber could be null if its the document, documentElement, or body |
| 3602 | const ownerDocument = otherNode.ownerDocument; |
| 3603 | return ( |
| 3604 | (otherNode as Instance | Document) === ownerDocument || |
| 3605 | otherNode === ownerDocument.documentElement || |
| 3606 | otherNode === ownerDocument.body |
| 3607 | ); |
| 3608 | } |
| 3609 | return isFragmentContainedByFiber(fragmentFiber, otherFiber); |
| 3610 | } |
| 3611 | if (documentPosition & Node.DOCUMENT_POSITION_PRECEDING) { |
| 3612 | return ( |
| 3613 | !!otherFiber && |
| 3614 | (otherFiber === precedingBoundaryFiber || |
| 3615 | isFiberPreceding(precedingBoundaryFiber, otherFiber)) |
| 3616 | ); |
| 3617 | } |
| 3618 | if (documentPosition & Node.DOCUMENT_POSITION_FOLLOWING) { |
| 3619 | return ( |
| 3620 | !!otherFiber && |
| 3621 | (otherFiber === followingBoundaryFiber || |
| 3622 | isFiberFollowing(followingBoundaryFiber, otherFiber)) |
| 3623 | ); |
| 3624 | } |
| 3625 | |
| 3626 | return false; |
| 3627 | } |
| 3628 | |
| 3629 | function scrollTextNodeIntoView( |
| 3630 | textNode: TextInstance, |
| 3631 | resolvedAlignToTop: boolean, |
| 3632 | ): void { |
| 3633 | const range = textNode.ownerDocument.createRange(); |
| 3634 | range.selectNodeContents(textNode); |
| 3635 | const rect = range.getBoundingClientRect(); |
| 3636 | const scrollY = resolvedAlignToTop |
| 3637 | ? window.scrollY + rect.top |
| 3638 | : window.scrollY + rect.bottom - window.innerHeight; |
| 3639 | window.scrollTo(window.scrollX + rect.left, scrollY); |
| 3640 | } |
| 3641 | |
| 3642 | if (enableFragmentRefsScrollIntoView) { |
| 3643 | // $FlowFixMe[prop-missing] |
| 3644 | FragmentInstance.prototype.scrollIntoView = function ( |
| 3645 | this: FragmentInstanceType, |
| 3646 | alignToTop?: boolean, |
| 3647 | ): void { |
| 3648 | if (typeof alignToTop === 'object') { |
| 3649 | throw new Error( |
| 3650 | 'FragmentInstance.scrollIntoView() does not support ' + |
| 3651 | 'scrollIntoViewOptions. Use the alignToTop boolean instead.', |
| 3652 | ); |
| 3653 | } |
| 3654 | // First, get the children nodes |
| 3655 | const children: Array<Fiber> = []; |
| 3656 | traverseFragmentInstancesAndTextInstances( |
| 3657 | this._fragmentFiber, |
| 3658 | collectChildren, |
| 3659 | children, |
| 3660 | ); |
| 3661 | |
| 3662 | const resolvedAlignToTop = alignToTop !== false; |
| 3663 | |
| 3664 | // If there are no children, we can use the parent and siblings to determine a position |
| 3665 | if (children.length === 0) { |
| 3666 | const hostSiblings = getFragmentInstanceOrTextInstanceSiblings( |
| 3667 | this._fragmentFiber, |
| 3668 | ); |
| 3669 | const targetFiber = resolvedAlignToTop |
| 3670 | ? hostSiblings[1] || |
| 3671 | hostSiblings[0] || |
| 3672 | getFragmentParentInstanceOrContainerFiber(this._fragmentFiber) |
| 3673 | : hostSiblings[0] || hostSiblings[1]; |
| 3674 | |
| 3675 | if (targetFiber === null) { |
| 3676 | return; |
| 3677 | } |
| 3678 | // For text node siblings, use Range API to scroll to their position |
| 3679 | if (enableFragmentRefsTextNodes && targetFiber.tag === HostText) { |
| 3680 | const textNode = getInstanceFromHostFiber<TextInstance>(targetFiber); |
| 3681 | scrollTextNodeIntoView(textNode, resolvedAlignToTop); |
| 3682 | return; |
| 3683 | } |
| 3684 | const target = getInstanceFromHostFiber<Instance | Container>( |
| 3685 | targetFiber, |
| 3686 | ); |
| 3687 | // If the parent host fiber is a HostRoot, the target is a Container |
| 3688 | // which is not necessarily an Element with a scrollIntoView method. |
| 3689 | if (target.nodeType === DOCUMENT_NODE) { |
| 3690 | // A Document is always in view. |
| 3691 | } else if (target.nodeType === DOCUMENT_FRAGMENT_NODE) { |
| 3692 | const fragment = target as any as DocumentFragment; |
| 3693 | // ShadowRoot always has a host: https://dom.spec.whatwg.org/#ref-for-concept-documentfragment-host%E2%91%A5 |
| 3694 | // A generic DocumentFragment doesn't implement this property but conceptually |
| 3695 | // host is a nullable Element: https://dom.spec.whatwg.org/#concept-documentfragment-host |
| 3696 | const host = |
| 3697 | 'host' in fragment ? (fragment as any as ShadowRoot).host : null; |
| 3698 | if (host !== null) { |
| 3699 | // The ShadowRoot's host element marks the position where the |
| 3700 | // fragment's content would appear. |
| 3701 | host.scrollIntoView(alignToTop); |
| 3702 | } else if (__DEV__) { |
| 3703 | console.warn( |
| 3704 | 'You are attempting to scroll a FragmentInstance that is only ' + |
| 3705 | 'mounted inside a detached DocumentFragment. No scroll was ' + |
| 3706 | 'performed.', |
| 3707 | ); |
| 3708 | } |
| 3709 | return; |
| 3710 | } else { |
| 3711 | // Narrowed down to Element by nodeType check above, but Flow doesn't know that. |
| 3712 | const element = target as any as Element; |
| 3713 | element.scrollIntoView(alignToTop); |
| 3714 | } |
| 3715 | } |
| 3716 | |
| 3717 | let i = resolvedAlignToTop ? children.length - 1 : 0; |
| 3718 | while (i !== (resolvedAlignToTop ? -1 : children.length)) { |
| 3719 | const child = children[i]; |
| 3720 | // For text nodes, use Range API to scroll to their position |
| 3721 | if (enableFragmentRefsTextNodes && child.tag === HostText) { |
| 3722 | const textNode = getInstanceFromHostFiber<TextInstance>(child); |
| 3723 | scrollTextNodeIntoView(textNode, resolvedAlignToTop); |
| 3724 | i += resolvedAlignToTop ? -1 : 1; |
| 3725 | continue; |
| 3726 | } |
| 3727 | const instance = getInstanceFromHostFiber<Instance>(child); |
| 3728 | instance.scrollIntoView(alignToTop); |
| 3729 | i += resolvedAlignToTop ? -1 : 1; |
| 3730 | } |
| 3731 | }; |
| 3732 | } |
| 3733 | |
| 3734 | function addFragmentHandleToFiber( |
| 3735 | child: Fiber, |
| 3736 | fragmentInstance: FragmentInstanceType, |
| 3737 | ): boolean { |
| 3738 | if (enableFragmentRefsInstanceHandles) { |
| 3739 | const instance = getInstanceFromHostFiber<Instance | TextInstance>( |
| 3740 | child, |
| 3741 | ) as any as HostNodeWithFragmentHandles; |
| 3742 | addFragmentHandleToInstance(instance, fragmentInstance); |
| 3743 | } |
| 3744 | return false; |
| 3745 | } |
| 3746 | |
| 3747 | function addFragmentHandleToInstance( |
| 3748 | instance: HostNodeWithFragmentHandles, |
| 3749 | fragmentInstance: FragmentInstanceType, |
| 3750 | ): void { |
| 3751 | if (enableFragmentRefsInstanceHandles) { |
| 3752 | if (instance.reactFragments == null) { |
| 3753 | instance.reactFragments = new Set(); |
| 3754 | } |
| 3755 | instance.reactFragments.add(fragmentInstance); |
| 3756 | } |
| 3757 | } |
| 3758 | |
| 3759 | export function createFragmentInstance( |
| 3760 | fragmentFiber: Fiber, |
| 3761 | ): FragmentInstanceType { |
| 3762 | const fragmentInstance = new (FragmentInstance as any)(fragmentFiber); |
| 3763 | if (enableFragmentRefsInstanceHandles) { |
| 3764 | traverseFragmentInstancesAndTextInstances( |
| 3765 | fragmentFiber, |
| 3766 | addFragmentHandleToFiber, |
| 3767 | fragmentInstance, |
| 3768 | ); |
| 3769 | } |
| 3770 | return fragmentInstance; |
| 3771 | } |
| 3772 | |
| 3773 | export function updateFragmentInstanceFiber( |
| 3774 | fragmentFiber: Fiber, |
| 3775 | instance: FragmentInstanceType, |
| 3776 | ): void { |
| 3777 | instance._fragmentFiber = fragmentFiber; |
| 3778 | } |
| 3779 | |
| 3780 | export function commitNewChildToFragmentInstance( |
| 3781 | childInstance: InstanceWithFragmentHandles | Text, |
| 3782 | fragmentInstance: FragmentInstanceType, |
| 3783 | ): void { |
| 3784 | const eventListeners = fragmentInstance._eventListeners; |
| 3785 | if (eventListeners !== null) { |
| 3786 | for (let i = 0; i < eventListeners.length; i++) { |
| 3787 | const {type, attachedListener, optionsOrUseCapture} = eventListeners[i]; |
| 3788 | childInstance.addEventListener( |
| 3789 | type, |
| 3790 | attachedListener, |
| 3791 | getAttachOptions(optionsOrUseCapture), |
| 3792 | ); |
| 3793 | } |
| 3794 | } |
| 3795 | // Observers and fragment handles only apply to element children. |
| 3796 | if (childInstance.nodeType === TEXT_NODE) { |
| 3797 | return; |
| 3798 | } |
| 3799 | const instance: InstanceWithFragmentHandles = childInstance as any; |
| 3800 | if (fragmentInstance._observers !== null) { |
| 3801 | fragmentInstance._observers.forEach(observer => { |
| 3802 | observer.observe(instance); |
| 3803 | }); |
| 3804 | } |
| 3805 | if (enableFragmentRefsInstanceHandles) { |
| 3806 | addFragmentHandleToInstance(instance, fragmentInstance); |
| 3807 | } |
| 3808 | } |
| 3809 | |
| 3810 | export function deleteChildFromFragmentInstance( |
| 3811 | childInstance: InstanceWithFragmentHandles | Text, |
| 3812 | fragmentInstance: FragmentInstanceType, |
| 3813 | ): void { |
| 3814 | const eventListeners = fragmentInstance._eventListeners; |
| 3815 | if (eventListeners !== null) { |
| 3816 | for (let i = 0; i < eventListeners.length; i++) { |
| 3817 | const {type, attachedListener, optionsOrUseCapture} = eventListeners[i]; |
| 3818 | childInstance.removeEventListener( |
| 3819 | type, |
| 3820 | attachedListener, |
| 3821 | getAttachOptions(optionsOrUseCapture), |
| 3822 | ); |
| 3823 | } |
| 3824 | } |
| 3825 | if (childInstance.nodeType === TEXT_NODE) { |
| 3826 | return; |
| 3827 | } |
| 3828 | const instance: InstanceWithFragmentHandles = childInstance as any; |
| 3829 | if (enableFragmentRefsInstanceHandles) { |
| 3830 | if (instance.reactFragments != null) { |
| 3831 | instance.reactFragments.delete(fragmentInstance); |
| 3832 | } |
| 3833 | } |
| 3834 | } |
| 3835 | |
| 3836 | export function clearContainer(container: Container): void { |
| 3837 | const nodeType = container.nodeType; |
| 3838 | if (nodeType === DOCUMENT_NODE) { |
| 3839 | clearContainerSparingly(container); |
| 3840 | } else if (nodeType === ELEMENT_NODE) { |
| 3841 | switch (container.nodeName) { |
| 3842 | case 'HEAD': |
| 3843 | case 'HTML': |
| 3844 | case 'BODY': |
| 3845 | clearContainerSparingly(container); |
| 3846 | return; |
| 3847 | default: { |
| 3848 | container.textContent = ''; |
| 3849 | } |
| 3850 | } |
| 3851 | } |
| 3852 | } |
| 3853 | |
| 3854 | function clearContainerSparingly(container: Node) { |
| 3855 | let node; |
| 3856 | let nextNode: ?Node = container.firstChild; |
| 3857 | if (nextNode && nextNode.nodeType === DOCUMENT_TYPE_NODE) { |
| 3858 | nextNode = nextNode.nextSibling; |
| 3859 | } |
| 3860 | while (nextNode) { |
| 3861 | node = nextNode; |
| 3862 | nextNode = nextNode.nextSibling; |
| 3863 | switch (node.nodeName) { |
| 3864 | case 'HTML': |
| 3865 | case 'HEAD': |
| 3866 | case 'BODY': { |
| 3867 | const element: Element = node as any; |
| 3868 | clearContainerSparingly(element); |
| 3869 | // If these singleton instances had previously been rendered with React they |
| 3870 | // may still hold on to references to the previous fiber tree. We detatch them |
| 3871 | // prospectively to reset them to a baseline starting state since we cannot create |
| 3872 | // new instances. |
| 3873 | detachDeletedInstance(element); |
| 3874 | continue; |
| 3875 | } |
| 3876 | // Script tags are retained to avoid an edge case bug. Normally scripts will execute if they |
| 3877 | // are ever inserted into the DOM. However when streaming if a script tag is opened but not |
| 3878 | // yet closed some browsers create and insert the script DOM Node but the script cannot execute |
| 3879 | // yet until the closing tag is parsed. If something causes React to call clearContainer while |
| 3880 | // this DOM node is in the document but not yet executable the DOM node will be removed from the |
| 3881 | // document and when the script closing tag comes in the script will not end up running. This seems |
| 3882 | // to happen in Chrome/Firefox but not Safari at the moment though this is not necessarily specified |
| 3883 | // behavior so it could change in future versions of browsers. While leaving all scripts is broader |
| 3884 | // than strictly necessary this is the least amount of additional code to avoid this breaking |
| 3885 | // edge case. |
| 3886 | // |
| 3887 | // Style tags are retained because they may likely come from 3rd party scripts and extensions |
| 3888 | case 'SCRIPT': |
| 3889 | case 'STYLE': { |
| 3890 | continue; |
| 3891 | } |
| 3892 | // Stylesheet tags are retained because they may likely come from 3rd party scripts and extensions |
| 3893 | case 'LINK': { |
| 3894 | if ( |
| 3895 | (node as any as HTMLLinkElement).rel.toLowerCase() === 'stylesheet' |
| 3896 | ) { |
| 3897 | continue; |
| 3898 | } |
| 3899 | } |
| 3900 | } |
| 3901 | container.removeChild(node); |
| 3902 | } |
| 3903 | return; |
| 3904 | } |
| 3905 | |
| 3906 | function clearHead(head: Element): void { |
| 3907 | let node = head.firstChild; |
| 3908 | while (node) { |
| 3909 | const nextNode = node.nextSibling; |
| 3910 | const nodeName = node.nodeName; |
| 3911 | if ( |
| 3912 | isMarkedHoistable(node) || |
| 3913 | nodeName === 'SCRIPT' || |
| 3914 | nodeName === 'STYLE' || |
| 3915 | (nodeName === 'LINK' && |
| 3916 | (node as any as HTMLLinkElement).rel.toLowerCase() === 'stylesheet') |
| 3917 | ) { |
| 3918 | // retain these nodes |
| 3919 | } else { |
| 3920 | head.removeChild(node); |
| 3921 | } |
| 3922 | node = nextNode; |
| 3923 | } |
| 3924 | return; |
| 3925 | } |
| 3926 | |
| 3927 | // Making this so we can eventually move all of the instance caching to the commit phase. |
| 3928 | // Currently this is only used to associate fiber and props to instances for hydrating |
| 3929 | // HostSingletons. The reason we need it here is we only want to make this binding on commit |
| 3930 | // because only one fiber can own the instance at a time and render can fail/restart |
| 3931 | export function bindInstance( |
| 3932 | instance: Instance, |
| 3933 | props: Props, |
| 3934 | internalInstanceHandle: mixed, |
| 3935 | ) { |
| 3936 | precacheFiberNode(internalInstanceHandle as any, instance); |
| 3937 | updateFiberProps(instance, props); |
| 3938 | } |
| 3939 | |
| 3940 | // ------------------- |
| 3941 | // Hydration |
| 3942 | // ------------------- |
| 3943 | |
| 3944 | export const supportsHydration = true; |
| 3945 | |
| 3946 | export function canHydrateInstance( |
| 3947 | instance: HydratableInstance, |
| 3948 | type: string, |
| 3949 | props: Props, |
| 3950 | inRootOrSingleton: boolean, |
| 3951 | ): null | Instance { |
| 3952 | while (instance.nodeType === ELEMENT_NODE) { |
| 3953 | const element: Element = instance as any; |
| 3954 | const anyProps = props as any; |
| 3955 | if (element.nodeName.toLowerCase() !== type.toLowerCase()) { |
| 3956 | if (!inRootOrSingleton) { |
| 3957 | // Usually we error for mismatched tags. |
| 3958 | if ( |
| 3959 | element.nodeName === 'INPUT' && |
| 3960 | (element as any).type === 'hidden' |
| 3961 | ) { |
| 3962 | // If we have extra hidden inputs, we don't mismatch. This allows us to embed |
| 3963 | // extra form data in the original form. |
| 3964 | } else { |
| 3965 | return null; |
| 3966 | } |
| 3967 | } |
| 3968 | // In root or singleton parents we skip past mismatched instances. |
| 3969 | } else if (!inRootOrSingleton) { |
| 3970 | // Match |
| 3971 | if (type === 'input' && (element as any).type === 'hidden') { |
| 3972 | if (__DEV__) { |
| 3973 | checkAttributeStringCoercion(anyProps.name, 'name'); |
| 3974 | } |
| 3975 | const name = anyProps.name == null ? null : '' + anyProps.name; |
| 3976 | if ( |
| 3977 | anyProps.type !== 'hidden' || |
| 3978 | element.getAttribute('name') !== name |
| 3979 | ) { |
| 3980 | // Skip past hidden inputs unless that's what we're looking for. This allows us |
| 3981 | // embed extra form data in the original form. |
| 3982 | } else { |
| 3983 | return element; |
| 3984 | } |
| 3985 | } else { |
| 3986 | return element; |
| 3987 | } |
| 3988 | } else if (isMarkedHoistable(element)) { |
| 3989 | // We've already claimed this as a hoistable which isn't hydrated this way so we skip past it. |
| 3990 | } else { |
| 3991 | // We have an Element with the right type. |
| 3992 | |
| 3993 | // We are going to try to exclude it if we can definitely identify it as a hoisted Node or if |
| 3994 | // we can guess that the node is likely hoisted or was inserted by a 3rd party script or browser extension |
| 3995 | // using high entropy attributes for certain types. This technique will fail for strange insertions like |
| 3996 | // extension prepending <div> in the <body> but that already breaks before and that is an edge case. |
| 3997 | switch (type) { |
| 3998 | // case 'title': |
| 3999 | //We assume all titles are matchable. You should only have one in the Document, at least in a hoistable scope |
| 4000 | // and if you are a HostComponent with type title we must either be in an <svg> context or this title must have an `itemProp` prop. |
| 4001 | case 'meta': { |
| 4002 | // The only way to opt out of hoisting meta tags is to give it an itemprop attribute. We assume there will be |
| 4003 | // not 3rd party meta tags that are prepended, accepting the cases where this isn't true because meta tags |
| 4004 | // are usually only functional for SSR so even in a rare case where we did bind to an injected tag the runtime |
| 4005 | // implications are minimal |
| 4006 | if (!element.hasAttribute('itemprop')) { |
| 4007 | // This is a Hoistable |
| 4008 | break; |
| 4009 | } |
| 4010 | return element; |
| 4011 | } |
| 4012 | case 'link': { |
| 4013 | // Links come in many forms and we do expect 3rd parties to inject them into <head> / <body>. We exclude known resources |
| 4014 | // and then use high-entroy attributes like href which are almost always used and almost always unique to filter out unlikely |
| 4015 | // matches. |
| 4016 | const rel = element.getAttribute('rel'); |
| 4017 | if (rel === 'stylesheet' && element.hasAttribute('data-precedence')) { |
| 4018 | // This is a stylesheet resource |
| 4019 | break; |
| 4020 | } else if ( |
| 4021 | rel !== anyProps.rel || |
| 4022 | element.getAttribute('href') !== |
| 4023 | (anyProps.href == null || anyProps.href === '' |
| 4024 | ? null |
| 4025 | : anyProps.href) || |
| 4026 | element.getAttribute('crossorigin') !== |
| 4027 | (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) || |
| 4028 | element.getAttribute('title') !== |
| 4029 | (anyProps.title == null ? null : anyProps.title) |
| 4030 | ) { |
| 4031 | // rel + href should usually be enough to uniquely identify a link however crossOrigin can vary for rel preconnect |
| 4032 | // and title could vary for rel alternate |
| 4033 | break; |
| 4034 | } |
| 4035 | return element; |
| 4036 | } |
| 4037 | case 'style': { |
| 4038 | // Styles are hard to match correctly. We can exclude known resources but otherwise we accept the fact that a non-hoisted style tags |
| 4039 | // in <head> or <body> are likely never going to be unmounted given their position in the document and the fact they likely hold global styles |
| 4040 | if (element.hasAttribute('data-precedence')) { |
| 4041 | // This is a style resource |
| 4042 | break; |
| 4043 | } |
| 4044 | return element; |
| 4045 | } |
| 4046 | case 'script': { |
| 4047 | // Scripts are a little tricky, we exclude known resources and then similar to links try to use high-entropy attributes |
| 4048 | // to reject poor matches. One challenge with scripts are inline scripts. We don't attempt to check text content which could |
| 4049 | // in theory lead to a hydration error later if a 3rd party injected an inline script before the React rendered nodes. |
| 4050 | // Falling back to client rendering if this happens should be seemless though so we will try this hueristic and revisit later |
| 4051 | // if we learn it is problematic |
| 4052 | const srcAttr = element.getAttribute('src'); |
| 4053 | if ( |
| 4054 | srcAttr !== (anyProps.src == null ? null : anyProps.src) || |
| 4055 | element.getAttribute('type') !== |
| 4056 | (anyProps.type == null ? null : anyProps.type) || |
| 4057 | element.getAttribute('crossorigin') !== |
| 4058 | (anyProps.crossOrigin == null ? null : anyProps.crossOrigin) |
| 4059 | ) { |
| 4060 | // This script is for a different src/type/crossOrigin. It may be a script resource |
| 4061 | // or it may just be a mistmatch |
| 4062 | if ( |
| 4063 | srcAttr && |
| 4064 | element.hasAttribute('async') && |
| 4065 | !element.hasAttribute('itemprop') |
| 4066 | ) { |
| 4067 | // This is an async script resource |
| 4068 | break; |
| 4069 | } |
| 4070 | } |
| 4071 | return element; |
| 4072 | } |
| 4073 | default: { |
| 4074 | // We have excluded the most likely cases of mismatch between hoistable tags, 3rd party script inserted tags, |
| 4075 | // and browser extension inserted tags. While it is possible this is not the right match it is a decent hueristic |
| 4076 | // that should work in the vast majority of cases. |
| 4077 | return element; |
| 4078 | } |
| 4079 | } |
| 4080 | } |
| 4081 | const nextInstance = getNextHydratableSibling(element); |
| 4082 | if (nextInstance === null) { |
| 4083 | break; |
| 4084 | } |
| 4085 | instance = nextInstance; |
| 4086 | } |
| 4087 | // This is a suspense boundary or Text node or we got the end. |
| 4088 | // Suspense Boundaries are never expected to be injected by 3rd parties. If we see one it should be matched |
| 4089 | // and this is a hydration error. |
| 4090 | // Text Nodes are also not expected to be injected by 3rd parties. This is less of a guarantee for <body> |
| 4091 | // but it seems reasonable and conservative to reject this as a hydration error as well |
| 4092 | return null; |
| 4093 | } |
| 4094 | |
| 4095 | export function canHydrateTextInstance( |
| 4096 | instance: HydratableInstance, |
| 4097 | text: string, |
| 4098 | inRootOrSingleton: boolean, |
| 4099 | ): null | TextInstance { |
| 4100 | // Empty strings are not parsed by HTML so there won't be a correct match here. |
| 4101 | if (text === '') return null; |
| 4102 | |
| 4103 | while (instance.nodeType !== TEXT_NODE) { |
| 4104 | if ( |
| 4105 | instance.nodeType === ELEMENT_NODE && |
| 4106 | instance.nodeName === 'INPUT' && |
| 4107 | (instance as any).type === 'hidden' |
| 4108 | ) { |
| 4109 | // If we have extra hidden inputs, we don't mismatch. This allows us to |
| 4110 | // embed extra form data in the original form. |
| 4111 | } else if (!inRootOrSingleton) { |
| 4112 | return null; |
| 4113 | } |
| 4114 | const nextInstance = getNextHydratableSibling(instance); |
| 4115 | if (nextInstance === null) { |
| 4116 | return null; |
| 4117 | } |
| 4118 | instance = nextInstance; |
| 4119 | } |
| 4120 | // This has now been refined to a text node. |
| 4121 | return instance as any as TextInstance; |
| 4122 | } |
| 4123 | |
| 4124 | function canHydrateHydrationBoundary( |
| 4125 | instance: HydratableInstance, |
| 4126 | inRootOrSingleton: boolean, |
| 4127 | ): null | SuspenseInstance | ActivityInstance { |
| 4128 | while (instance.nodeType !== COMMENT_NODE) { |
| 4129 | if ( |
| 4130 | instance.nodeType === ELEMENT_NODE && |
| 4131 | instance.nodeName === 'INPUT' && |
| 4132 | (instance as any).type === 'hidden' |
| 4133 | ) { |
| 4134 | // If we have extra hidden inputs, we don't mismatch. This allows us to |
| 4135 | // embed extra form data in the original form. |
| 4136 | } else if (!inRootOrSingleton) { |
| 4137 | return null; |
| 4138 | } |
| 4139 | const nextInstance = getNextHydratableSibling(instance); |
| 4140 | if (nextInstance === null) { |
| 4141 | return null; |
| 4142 | } |
| 4143 | instance = nextInstance; |
| 4144 | } |
| 4145 | // This has now been refined to a hydration boundary node. |
| 4146 | return instance as any; |
| 4147 | } |
| 4148 | |
| 4149 | export function canHydrateActivityInstance( |
| 4150 | instance: HydratableInstance, |
| 4151 | inRootOrSingleton: boolean, |
| 4152 | ): null | ActivityInstance { |
| 4153 | const hydratableInstance = canHydrateHydrationBoundary( |
| 4154 | instance, |
| 4155 | inRootOrSingleton, |
| 4156 | ); |
| 4157 | if ( |
| 4158 | hydratableInstance !== null && |
| 4159 | hydratableInstance.data === ACTIVITY_START_DATA |
| 4160 | ) { |
| 4161 | return hydratableInstance as any; |
| 4162 | } |
| 4163 | return null; |
| 4164 | } |
| 4165 | |
| 4166 | export function canHydrateSuspenseInstance( |
| 4167 | instance: HydratableInstance, |
| 4168 | inRootOrSingleton: boolean, |
| 4169 | ): null | SuspenseInstance { |
| 4170 | const hydratableInstance = canHydrateHydrationBoundary( |
| 4171 | instance, |
| 4172 | inRootOrSingleton, |
| 4173 | ); |
| 4174 | if ( |
| 4175 | hydratableInstance !== null && |
| 4176 | hydratableInstance.data !== ACTIVITY_START_DATA |
| 4177 | ) { |
| 4178 | return hydratableInstance as any; |
| 4179 | } |
| 4180 | return null; |
| 4181 | } |
| 4182 | |
| 4183 | export function isSuspenseInstancePending(instance: SuspenseInstance): boolean { |
| 4184 | return ( |
| 4185 | instance.data === SUSPENSE_PENDING_START_DATA || |
| 4186 | instance.data === SUSPENSE_QUEUED_START_DATA |
| 4187 | ); |
| 4188 | } |
| 4189 | |
| 4190 | export function isSuspenseInstanceFallback( |
| 4191 | instance: SuspenseInstance, |
| 4192 | ): boolean { |
| 4193 | return ( |
| 4194 | instance.data === SUSPENSE_FALLBACK_START_DATA || |
| 4195 | (instance.data === SUSPENSE_PENDING_START_DATA && |
| 4196 | instance.ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING) |
| 4197 | ); |
| 4198 | } |
| 4199 | |
| 4200 | export function getSuspenseInstanceFallbackErrorDetails( |
| 4201 | instance: SuspenseInstance, |
| 4202 | ): { |
| 4203 | digest: ?string, |
| 4204 | message?: string, |
| 4205 | stack?: string, |
| 4206 | componentStack?: string, |
| 4207 | } { |
| 4208 | const dataset = |
| 4209 | instance.nextSibling && |
| 4210 | (instance.nextSibling as any as HTMLElement).dataset; |
| 4211 | let digest, message, stack, componentStack; |
| 4212 | if (dataset) { |
| 4213 | digest = dataset.dgst; |
| 4214 | if (__DEV__) { |
| 4215 | message = dataset.msg; |
| 4216 | stack = dataset.stck; |
| 4217 | componentStack = dataset.cstck; |
| 4218 | } |
| 4219 | } |
| 4220 | if (__DEV__) { |
| 4221 | return { |
| 4222 | message, |
| 4223 | digest, |
| 4224 | stack, |
| 4225 | componentStack, |
| 4226 | }; |
| 4227 | } else { |
| 4228 | // Object gets DCE'd if constructed in tail position and matches callsite destructuring |
| 4229 | return { |
| 4230 | digest, |
| 4231 | }; |
| 4232 | } |
| 4233 | } |
| 4234 | |
| 4235 | export function registerSuspenseInstanceRetry( |
| 4236 | instance: SuspenseInstance, |
| 4237 | callback: () => void, |
| 4238 | ) { |
| 4239 | const ownerDocument = instance.ownerDocument; |
| 4240 | if (instance.data === SUSPENSE_QUEUED_START_DATA) { |
| 4241 | // The Fizz runtime has already queued this boundary for reveal. We wait for it |
| 4242 | // to be revealed and then retries. |
| 4243 | instance._reactRetry = callback; |
| 4244 | } else if ( |
| 4245 | // The Fizz runtime must have put this boundary into client render or complete |
| 4246 | // state after the render finished but before it committed. We need to call the |
| 4247 | // callback now rather than wait |
| 4248 | instance.data !== SUSPENSE_PENDING_START_DATA || |
| 4249 | // The boundary is still in pending status but the document has finished loading |
| 4250 | // before we could register the event handler that would have scheduled the retry |
| 4251 | // on load so we call the callback now. |
| 4252 | ownerDocument.readyState !== DOCUMENT_READY_STATE_LOADING |
| 4253 | ) { |
| 4254 | callback(); |
| 4255 | } else { |
| 4256 | // We're still in pending status and the document is still loading so we attach |
| 4257 | // a listener to the document load even and expose the retry on the instance for |
| 4258 | // the Fizz runtime to trigger if it ends up resolving this boundary |
| 4259 | const listener = () => { |
| 4260 | callback(); |
| 4261 | ownerDocument.removeEventListener('DOMContentLoaded', listener); |
| 4262 | }; |
| 4263 | ownerDocument.addEventListener('DOMContentLoaded', listener); |
| 4264 | instance._reactRetry = listener; |
| 4265 | } |
| 4266 | } |
| 4267 | |
| 4268 | export function canHydrateFormStateMarker( |
| 4269 | instance: HydratableInstance, |
| 4270 | inRootOrSingleton: boolean, |
| 4271 | ): null | FormStateMarkerInstance { |
| 4272 | while (instance.nodeType !== COMMENT_NODE) { |
| 4273 | if (!inRootOrSingleton) { |
| 4274 | return null; |
| 4275 | } |
| 4276 | const nextInstance = getNextHydratableSibling(instance); |
| 4277 | if (nextInstance === null) { |
| 4278 | return null; |
| 4279 | } |
| 4280 | instance = nextInstance; |
| 4281 | } |
| 4282 | const nodeData = (instance as any).data; |
| 4283 | if ( |
| 4284 | nodeData === FORM_STATE_IS_MATCHING || |
| 4285 | nodeData === FORM_STATE_IS_NOT_MATCHING |
| 4286 | ) { |
| 4287 | const markerInstance: FormStateMarkerInstance = instance as any; |
| 4288 | return markerInstance; |
| 4289 | } |
| 4290 | return null; |
| 4291 | } |
| 4292 | |
| 4293 | export function isFormStateMarkerMatching( |
| 4294 | markerInstance: FormStateMarkerInstance, |
| 4295 | ): boolean { |
| 4296 | return markerInstance.data === FORM_STATE_IS_MATCHING; |
| 4297 | } |
| 4298 | |
| 4299 | function getNextHydratable(node: ?Node) { |
| 4300 | // Skip non-hydratable nodes. |
| 4301 | for (; node != null; node = (node as any as Node).nextSibling) { |
| 4302 | const nodeType = node.nodeType; |
| 4303 | if (nodeType === ELEMENT_NODE || nodeType === TEXT_NODE) { |
| 4304 | break; |
| 4305 | } |
| 4306 | if (nodeType === COMMENT_NODE) { |
| 4307 | const data = (node as any).data; |
| 4308 | if ( |
| 4309 | data === SUSPENSE_START_DATA || |
| 4310 | data === SUSPENSE_FALLBACK_START_DATA || |
| 4311 | data === SUSPENSE_PENDING_START_DATA || |
| 4312 | data === SUSPENSE_QUEUED_START_DATA || |
| 4313 | data === ACTIVITY_START_DATA || |
| 4314 | data === FORM_STATE_IS_MATCHING || |
| 4315 | data === FORM_STATE_IS_NOT_MATCHING |
| 4316 | ) { |
| 4317 | break; |
| 4318 | } |
| 4319 | if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { |
| 4320 | return null; |
| 4321 | } |
| 4322 | } |
| 4323 | } |
| 4324 | return node as any; |
| 4325 | } |
| 4326 | |
| 4327 | export function getNextHydratableSibling( |
| 4328 | instance: HydratableInstance, |
| 4329 | ): null | HydratableInstance { |
| 4330 | return getNextHydratable(instance.nextSibling); |
| 4331 | } |
| 4332 | |
| 4333 | export function getFirstHydratableChild( |
| 4334 | parentInstance: Instance, |
| 4335 | ): null | HydratableInstance { |
| 4336 | return getNextHydratable(parentInstance.firstChild); |
| 4337 | } |
| 4338 | |
| 4339 | export function getFirstHydratableChildWithinContainer( |
| 4340 | parentContainer: Container, |
| 4341 | ): null | HydratableInstance { |
| 4342 | let parentElement: Element; |
| 4343 | switch (parentContainer.nodeType) { |
| 4344 | case DOCUMENT_NODE: |
| 4345 | parentElement = (parentContainer as any).body; |
| 4346 | break; |
| 4347 | default: { |
| 4348 | if (parentContainer.nodeName === 'HTML') { |
| 4349 | parentElement = (parentContainer as any).ownerDocument.body; |
| 4350 | } else { |
| 4351 | parentElement = parentContainer as any; |
| 4352 | } |
| 4353 | } |
| 4354 | } |
| 4355 | return getNextHydratable(parentElement.firstChild); |
| 4356 | } |
| 4357 | |
| 4358 | export function getFirstHydratableChildWithinActivityInstance( |
| 4359 | parentInstance: ActivityInstance, |
| 4360 | ): null | HydratableInstance { |
| 4361 | return getNextHydratable(parentInstance.nextSibling); |
| 4362 | } |
| 4363 | |
| 4364 | export function getFirstHydratableChildWithinSuspenseInstance( |
| 4365 | parentInstance: SuspenseInstance, |
| 4366 | ): null | HydratableInstance { |
| 4367 | return getNextHydratable(parentInstance.nextSibling); |
| 4368 | } |
| 4369 | |
| 4370 | // If it were possible to have more than one scope singleton in a DOM tree |
| 4371 | // we would need to model this as a stack but since you can only have one <head> |
| 4372 | // and head is the only singleton that is a scope in DOM we can get away with |
| 4373 | // tracking this as a single value. |
| 4374 | let previousHydratableOnEnteringScopedSingleton: null | HydratableInstance = |
| 4375 | null; |
| 4376 | |
| 4377 | export function getFirstHydratableChildWithinSingleton( |
| 4378 | type: string, |
| 4379 | singletonInstance: Instance, |
| 4380 | currentHydratableInstance: null | HydratableInstance, |
| 4381 | ): null | HydratableInstance { |
| 4382 | if (isSingletonScope(type)) { |
| 4383 | previousHydratableOnEnteringScopedSingleton = currentHydratableInstance; |
| 4384 | return getNextHydratable(singletonInstance.firstChild); |
| 4385 | } else { |
| 4386 | return currentHydratableInstance; |
| 4387 | } |
| 4388 | } |
| 4389 | |
| 4390 | export function getNextHydratableSiblingAfterSingleton( |
| 4391 | type: string, |
| 4392 | currentHydratableInstance: null | HydratableInstance, |
| 4393 | ): null | HydratableInstance { |
| 4394 | if (isSingletonScope(type)) { |
| 4395 | const previousHydratableInstance = |
| 4396 | previousHydratableOnEnteringScopedSingleton; |
| 4397 | previousHydratableOnEnteringScopedSingleton = null; |
| 4398 | return previousHydratableInstance; |
| 4399 | } else { |
| 4400 | return currentHydratableInstance; |
| 4401 | } |
| 4402 | } |
| 4403 | |
| 4404 | export function describeHydratableInstanceForDevWarnings( |
| 4405 | instance: HydratableInstance, |
| 4406 | ): string | {type: string, props: $ReadOnly<Props>} { |
| 4407 | // Reverse engineer a pseudo react-element from hydratable instance |
| 4408 | if (instance.nodeType === ELEMENT_NODE) { |
| 4409 | // Reverse engineer a set of props that can print for dev warnings |
| 4410 | return { |
| 4411 | type: instance.nodeName.toLowerCase(), |
| 4412 | props: getPropsFromElement(instance as any), |
| 4413 | }; |
| 4414 | } else if (instance.nodeType === COMMENT_NODE) { |
| 4415 | if (instance.data === ACTIVITY_START_DATA) { |
| 4416 | return { |
| 4417 | type: 'Activity', |
| 4418 | props: {}, |
| 4419 | }; |
| 4420 | } |
| 4421 | return { |
| 4422 | type: 'Suspense', |
| 4423 | props: {}, |
| 4424 | }; |
| 4425 | } else { |
| 4426 | return instance.nodeValue; |
| 4427 | } |
| 4428 | } |
| 4429 | |
| 4430 | export function validateHydratableInstance( |
| 4431 | type: string, |
| 4432 | props: Props, |
| 4433 | hostContext: HostContext, |
| 4434 | ): boolean { |
| 4435 | if (__DEV__) { |
| 4436 | // TODO: take namespace into account when validating. |
| 4437 | const hostContextDev: HostContextDev = hostContext as any; |
| 4438 | return validateDOMNesting(type, hostContextDev.ancestorInfo); |
| 4439 | } |
| 4440 | return true; |
| 4441 | } |
| 4442 | |
| 4443 | export function hydrateInstance( |
| 4444 | instance: Instance, |
| 4445 | type: string, |
| 4446 | props: Props, |
| 4447 | hostContext: HostContext, |
| 4448 | internalInstanceHandle: Object, |
| 4449 | ): boolean { |
| 4450 | precacheFiberNode(internalInstanceHandle, instance); |
| 4451 | // TODO: Possibly defer this until the commit phase where all the events |
| 4452 | // get attached. |
| 4453 | updateFiberProps(instance, props); |
| 4454 | |
| 4455 | return hydrateProperties(instance, type, props, hostContext); |
| 4456 | } |
| 4457 | |
| 4458 | // Returns a Map of properties that were different on the server. |
| 4459 | export function diffHydratedPropsForDevWarnings( |
| 4460 | instance: Instance, |
| 4461 | type: string, |
| 4462 | props: Props, |
| 4463 | hostContext: HostContext, |
| 4464 | ): null | $ReadOnly<Props> { |
| 4465 | return diffHydratedProperties(instance, type, props, hostContext); |
| 4466 | } |
| 4467 | |
| 4468 | export function validateHydratableTextInstance( |
| 4469 | text: string, |
| 4470 | hostContext: HostContext, |
| 4471 | ): boolean { |
| 4472 | if (__DEV__) { |
| 4473 | const hostContextDev = hostContext as any as HostContextDev; |
| 4474 | const ancestor = hostContextDev.ancestorInfo.current; |
| 4475 | if (ancestor != null) { |
| 4476 | return validateTextNesting( |
| 4477 | text, |
| 4478 | ancestor.tag, |
| 4479 | hostContextDev.ancestorInfo.implicitRootScope, |
| 4480 | ); |
| 4481 | } |
| 4482 | } |
| 4483 | return true; |
| 4484 | } |
| 4485 | |
| 4486 | export function hydrateTextInstance( |
| 4487 | textInstance: TextInstance, |
| 4488 | text: string, |
| 4489 | internalInstanceHandle: Object, |
| 4490 | parentInstanceProps: null | Props, |
| 4491 | ): boolean { |
| 4492 | precacheFiberNode(internalInstanceHandle, textInstance); |
| 4493 | |
| 4494 | return hydrateText(textInstance, text, parentInstanceProps); |
| 4495 | } |
| 4496 | |
| 4497 | // Returns the server text if it differs from the client. |
| 4498 | export function diffHydratedTextForDevWarnings( |
| 4499 | textInstance: TextInstance, |
| 4500 | text: string, |
| 4501 | parentProps: null | Props, |
| 4502 | ): null | string { |
| 4503 | if ( |
| 4504 | parentProps === null || |
| 4505 | parentProps[SUPPRESS_HYDRATION_WARNING] !== true |
| 4506 | ) { |
| 4507 | return diffHydratedText(textInstance, text); |
| 4508 | } |
| 4509 | return null; |
| 4510 | } |
| 4511 | |
| 4512 | export function hydrateActivityInstance( |
| 4513 | activityInstance: ActivityInstance, |
| 4514 | internalInstanceHandle: Object, |
| 4515 | ) { |
| 4516 | precacheFiberNode(internalInstanceHandle, activityInstance); |
| 4517 | } |
| 4518 | |
| 4519 | export function hydrateSuspenseInstance( |
| 4520 | suspenseInstance: SuspenseInstance, |
| 4521 | internalInstanceHandle: Object, |
| 4522 | ) { |
| 4523 | precacheFiberNode(internalInstanceHandle, suspenseInstance); |
| 4524 | } |
| 4525 | |
| 4526 | function getNextHydratableInstanceAfterHydrationBoundary( |
| 4527 | hydrationInstance: SuspenseInstance | ActivityInstance, |
| 4528 | ): null | HydratableInstance { |
| 4529 | let node = hydrationInstance.nextSibling; |
| 4530 | // Skip past all nodes within this suspense boundary. |
| 4531 | // There might be nested nodes so we need to keep track of how |
| 4532 | // deep we are and only break out when we're back on top. |
| 4533 | let depth = 0; |
| 4534 | while (node) { |
| 4535 | if (node.nodeType === COMMENT_NODE) { |
| 4536 | const data = (node as any).data as string; |
| 4537 | if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { |
| 4538 | if (depth === 0) { |
| 4539 | return getNextHydratableSibling(node as any); |
| 4540 | } else { |
| 4541 | depth--; |
| 4542 | } |
| 4543 | } else if ( |
| 4544 | data === SUSPENSE_START_DATA || |
| 4545 | data === SUSPENSE_FALLBACK_START_DATA || |
| 4546 | data === SUSPENSE_PENDING_START_DATA || |
| 4547 | data === SUSPENSE_QUEUED_START_DATA || |
| 4548 | data === ACTIVITY_START_DATA |
| 4549 | ) { |
| 4550 | depth++; |
| 4551 | } |
| 4552 | } |
| 4553 | node = node.nextSibling; |
| 4554 | } |
| 4555 | // TODO: Warn, we didn't find the end comment boundary. |
| 4556 | return null; |
| 4557 | } |
| 4558 | |
| 4559 | export function getNextHydratableInstanceAfterActivityInstance( |
| 4560 | activityInstance: ActivityInstance, |
| 4561 | ): null | HydratableInstance { |
| 4562 | return getNextHydratableInstanceAfterHydrationBoundary(activityInstance); |
| 4563 | } |
| 4564 | |
| 4565 | export function getNextHydratableInstanceAfterSuspenseInstance( |
| 4566 | suspenseInstance: SuspenseInstance, |
| 4567 | ): null | HydratableInstance { |
| 4568 | return getNextHydratableInstanceAfterHydrationBoundary(suspenseInstance); |
| 4569 | } |
| 4570 | |
| 4571 | // Returns the SuspenseInstance if this node is a direct child of a |
| 4572 | // SuspenseInstance. I.e. if its previous sibling is a Comment with |
| 4573 | // SUSPENSE_x_START_DATA. Otherwise, null. |
| 4574 | export function getParentHydrationBoundary( |
| 4575 | targetInstance: Node, |
| 4576 | ): null | SuspenseInstance | ActivityInstance { |
| 4577 | let node = targetInstance.previousSibling; |
| 4578 | // Skip past all nodes within this suspense boundary. |
| 4579 | // There might be nested nodes so we need to keep track of how |
| 4580 | // deep we are and only break out when we're back on top. |
| 4581 | let depth = 0; |
| 4582 | while (node) { |
| 4583 | if (node.nodeType === COMMENT_NODE) { |
| 4584 | const data = (node as any).data as string; |
| 4585 | if ( |
| 4586 | data === SUSPENSE_START_DATA || |
| 4587 | data === SUSPENSE_FALLBACK_START_DATA || |
| 4588 | data === SUSPENSE_PENDING_START_DATA || |
| 4589 | data === SUSPENSE_QUEUED_START_DATA || |
| 4590 | data === ACTIVITY_START_DATA |
| 4591 | ) { |
| 4592 | if (depth === 0) { |
| 4593 | return node as any as SuspenseInstance | ActivityInstance; |
| 4594 | } else { |
| 4595 | depth--; |
| 4596 | } |
| 4597 | } else if (data === SUSPENSE_END_DATA || data === ACTIVITY_END_DATA) { |
| 4598 | depth++; |
| 4599 | } |
| 4600 | } |
| 4601 | node = node.previousSibling; |
| 4602 | } |
| 4603 | return null; |
| 4604 | } |
| 4605 | |
| 4606 | export function commitHydratedContainer(container: Container): void { |
| 4607 | // Retry if any event replaying was blocked on this. |
| 4608 | retryIfBlockedOn(container); |
| 4609 | } |
| 4610 | |
| 4611 | export function commitHydratedActivityInstance( |
| 4612 | activityInstance: ActivityInstance, |
| 4613 | ): void { |
| 4614 | // Retry if any event replaying was blocked on this. |
| 4615 | retryIfBlockedOn(activityInstance); |
| 4616 | } |
| 4617 | |
| 4618 | export function commitHydratedSuspenseInstance( |
| 4619 | suspenseInstance: SuspenseInstance, |
| 4620 | ): void { |
| 4621 | // Retry if any event replaying was blocked on this. |
| 4622 | retryIfBlockedOn(suspenseInstance); |
| 4623 | } |
| 4624 | |
| 4625 | export function flushHydrationEvents(): void { |
| 4626 | if (enableHydrationChangeEvent) { |
| 4627 | flushEventReplaying(); |
| 4628 | } |
| 4629 | } |
| 4630 | |
| 4631 | export function shouldDeleteUnhydratedTailInstances( |
| 4632 | parentType: string, |
| 4633 | ): boolean { |
| 4634 | return parentType !== 'form' && parentType !== 'button'; |
| 4635 | } |
| 4636 | |
| 4637 | // ------------------- |
| 4638 | // Test Selectors |
| 4639 | // ------------------- |
| 4640 | |
| 4641 | export const supportsTestSelectors = true; |
| 4642 | |
| 4643 | export function findFiberRoot(node: Instance): null | FiberRoot { |
| 4644 | const stack = [node]; |
| 4645 | let index = 0; |
| 4646 | while (index < stack.length) { |
| 4647 | const current = stack[index++]; |
| 4648 | if (isContainerMarkedAsRoot(current)) { |
| 4649 | return getInstanceFromNodeDOMTree(current) as any as FiberRoot; |
| 4650 | } |
| 4651 | stack.push(...current.children); |
| 4652 | } |
| 4653 | return null; |
| 4654 | } |
| 4655 | |
| 4656 | export function getBoundingRect(node: Instance): BoundingRect { |
| 4657 | const rect = node.getBoundingClientRect(); |
| 4658 | return { |
| 4659 | x: rect.left, |
| 4660 | y: rect.top, |
| 4661 | width: rect.width, |
| 4662 | height: rect.height, |
| 4663 | }; |
| 4664 | } |
| 4665 | |
| 4666 | export function matchAccessibilityRole(node: Instance, role: string): boolean { |
| 4667 | if (hasRole(node, role)) { |
| 4668 | return true; |
| 4669 | } |
| 4670 | |
| 4671 | return false; |
| 4672 | } |
| 4673 | |
| 4674 | export function getTextContent(fiber: Fiber): string | null { |
| 4675 | switch (fiber.tag) { |
| 4676 | case HostHoistable: |
| 4677 | case HostSingleton: |
| 4678 | case HostComponent: |
| 4679 | let textContent = ''; |
| 4680 | const childNodes = fiber.stateNode.childNodes; |
| 4681 | for (let i = 0; i < childNodes.length; i++) { |
| 4682 | const childNode = childNodes[i]; |
| 4683 | if (childNode.nodeType === Node.TEXT_NODE) { |
| 4684 | textContent += childNode.textContent; |
| 4685 | } |
| 4686 | } |
| 4687 | return textContent; |
| 4688 | case HostText: |
| 4689 | return fiber.stateNode.textContent; |
| 4690 | } |
| 4691 | |
| 4692 | return null; |
| 4693 | } |
| 4694 | |
| 4695 | export function isHiddenSubtree(fiber: Fiber): boolean { |
| 4696 | return fiber.tag === HostComponent && fiber.memoizedProps.hidden === true; |
| 4697 | } |
| 4698 | |
| 4699 | export function setFocusIfFocusable( |
| 4700 | node: Instance, |
| 4701 | focusOptions?: FocusOptions, |
| 4702 | ): boolean { |
| 4703 | // The logic for determining if an element is focusable is kind of complex, |
| 4704 | // and since we want to actually change focus anyway- we can just skip it. |
| 4705 | // Instead we'll just listen for a "focus" event to verify that focus was set. |
| 4706 | // |
| 4707 | // We could compare the node to document.activeElement after focus, |
| 4708 | // but this would not handle the case where application code managed focus to automatically blur. |
| 4709 | const element = node as any as HTMLElement; |
| 4710 | |
| 4711 | // If this element is already the active element, it's focusable and already |
| 4712 | // focused. Calling .focus() on it would be a no-op (no focus event fires), |
| 4713 | // so we short-circuit here. |
| 4714 | if (element.ownerDocument.activeElement === element) { |
| 4715 | return true; |
| 4716 | } |
| 4717 | |
| 4718 | let didFocus = false; |
| 4719 | const handleFocus = () => { |
| 4720 | didFocus = true; |
| 4721 | }; |
| 4722 | |
| 4723 | try { |
| 4724 | // Listen on the document in the capture phase so we detect focus even when |
| 4725 | // it lands on a different element than the one we called .focus() on. This |
| 4726 | // happens with <label> elements (focus delegates to the associated input) |
| 4727 | // and shadow hosts with delegatesFocus. |
| 4728 | element.ownerDocument.addEventListener('focus', handleFocus, true); |
| 4729 | // $FlowFixMe[method-unbinding] |
| 4730 | (element.focus || HTMLElement.prototype.focus).call(element, focusOptions); |
| 4731 | } finally { |
| 4732 | element.ownerDocument.removeEventListener('focus', handleFocus, true); |
| 4733 | } |
| 4734 | |
| 4735 | return didFocus; |
| 4736 | } |
| 4737 | |
| 4738 | type RectRatio = { |
| 4739 | ratio: number, |
| 4740 | rect: BoundingRect, |
| 4741 | }; |
| 4742 | |
| 4743 | export function setupIntersectionObserver( |
| 4744 | targets: Array<Instance>, |
| 4745 | callback: ObserveVisibleRectsCallback, |
| 4746 | options?: IntersectionObserverOptions, |
| 4747 | ): { |
| 4748 | disconnect: () => void, |
| 4749 | observe: (instance: Instance) => void, |
| 4750 | unobserve: (instance: Instance) => void, |
| 4751 | } { |
| 4752 | const rectRatioCache: Map<Instance, RectRatio> = new Map(); |
| 4753 | targets.forEach(target => { |
| 4754 | rectRatioCache.set(target, { |
| 4755 | rect: getBoundingRect(target), |
| 4756 | ratio: 0, |
| 4757 | }); |
| 4758 | }); |
| 4759 | |
| 4760 | const handleIntersection = (entries: Array<IntersectionObserverEntry>) => { |
| 4761 | entries.forEach(entry => { |
| 4762 | const {boundingClientRect, intersectionRatio, target} = entry; |
| 4763 | rectRatioCache.set(target, { |
| 4764 | rect: { |
| 4765 | x: boundingClientRect.left, |
| 4766 | y: boundingClientRect.top, |
| 4767 | width: boundingClientRect.width, |
| 4768 | height: boundingClientRect.height, |
| 4769 | }, |
| 4770 | ratio: intersectionRatio, |
| 4771 | }); |
| 4772 | }); |
| 4773 | |
| 4774 | callback(Array.from(rectRatioCache.values())); |
| 4775 | }; |
| 4776 | |
| 4777 | const observer = new IntersectionObserver(handleIntersection, options); |
| 4778 | targets.forEach(target => { |
| 4779 | observer.observe(target as any); |
| 4780 | }); |
| 4781 | |
| 4782 | return { |
| 4783 | disconnect: () => observer.disconnect(), |
| 4784 | observe: target => { |
| 4785 | rectRatioCache.set(target, { |
| 4786 | rect: getBoundingRect(target), |
| 4787 | ratio: 0, |
| 4788 | }); |
| 4789 | observer.observe(target as any); |
| 4790 | }, |
| 4791 | unobserve: target => { |
| 4792 | rectRatioCache.delete(target); |
| 4793 | observer.unobserve(target as any); |
| 4794 | }, |
| 4795 | }; |
| 4796 | } |
| 4797 | |
| 4798 | export function requestPostPaintCallback(callback: (time: number) => void) { |
| 4799 | localRequestAnimationFrame(() => { |
| 4800 | localRequestAnimationFrame(time => callback(time)); |
| 4801 | }); |
| 4802 | } |
| 4803 | |
| 4804 | // ------------------- |
| 4805 | // Singletons |
| 4806 | // ------------------- |
| 4807 | |
| 4808 | export const supportsSingletons = true; |
| 4809 | |
| 4810 | export function isHostSingletonType(type: string): boolean { |
| 4811 | return type === 'html' || type === 'head' || type === 'body'; |
| 4812 | } |
| 4813 | |
| 4814 | export function resolveSingletonInstance( |
| 4815 | type: string, |
| 4816 | props: Props, |
| 4817 | rootContainerInstance: Container, |
| 4818 | hostContext: HostContext, |
| 4819 | validateDOMNestingDev: boolean, |
| 4820 | ): Instance { |
| 4821 | if (__DEV__) { |
| 4822 | const hostContextDev = hostContext as any as HostContextDev; |
| 4823 | if (validateDOMNestingDev) { |
| 4824 | validateDOMNesting(type, hostContextDev.ancestorInfo); |
| 4825 | } |
| 4826 | } |
| 4827 | const ownerDocument = getOwnerDocumentFromRootContainer( |
| 4828 | rootContainerInstance, |
| 4829 | ); |
| 4830 | switch (type) { |
| 4831 | case 'html': { |
| 4832 | const documentElement = ownerDocument.documentElement; |
| 4833 | if (!documentElement) { |
| 4834 | throw new Error( |
| 4835 | 'React expected an <html> element (document.documentElement) to exist in the Document but one was' + |
| 4836 | ' not found. React never removes the documentElement for any Document it renders into so' + |
| 4837 | ' the cause is likely in some other script running on this page.', |
| 4838 | ); |
| 4839 | } |
| 4840 | return documentElement; |
| 4841 | } |
| 4842 | case 'head': { |
| 4843 | const head = ownerDocument.head; |
| 4844 | if (!head) { |
| 4845 | throw new Error( |
| 4846 | 'React expected a <head> element (document.head) to exist in the Document but one was' + |
| 4847 | ' not found. React never removes the head for any Document it renders into so' + |
| 4848 | ' the cause is likely in some other script running on this page.', |
| 4849 | ); |
| 4850 | } |
| 4851 | return head; |
| 4852 | } |
| 4853 | case 'body': { |
| 4854 | const body = ownerDocument.body; |
| 4855 | if (!body) { |
| 4856 | throw new Error( |
| 4857 | 'React expected a <body> element (document.body) to exist in the Document but one was' + |
| 4858 | ' not found. React never removes the body for any Document it renders into so' + |
| 4859 | ' the cause is likely in some other script running on this page.', |
| 4860 | ); |
| 4861 | } |
| 4862 | return body; |
| 4863 | } |
| 4864 | default: { |
| 4865 | throw new Error( |
| 4866 | 'resolveSingletonInstance was called with an element type that is not supported. This is a bug in React.', |
| 4867 | ); |
| 4868 | } |
| 4869 | } |
| 4870 | } |
| 4871 | |
| 4872 | export function acquireSingletonInstance( |
| 4873 | type: string, |
| 4874 | props: Props, |
| 4875 | instance: Instance, |
| 4876 | internalInstanceHandle: Object, |
| 4877 | ): void { |
| 4878 | if (__DEV__) { |
| 4879 | if ( |
| 4880 | // If this instance is the container then it is invalid to acquire it as a singleton however |
| 4881 | // the DOM nesting validation will already warn for this and the message below isn't semantically |
| 4882 | // aligned with the actual fix you need to make so we omit the warning in this case |
| 4883 | !isContainerMarkedAsRoot(instance) && |
| 4884 | // If this instance isn't the root but is currently owned by a different HostSingleton instance then |
| 4885 | // we we need to warn that you are rendering more than one singleton at a time. |
| 4886 | getInstanceFromNodeDOMTree(instance) |
| 4887 | ) { |
| 4888 | const tagName = instance.tagName.toLowerCase(); |
| 4889 | console.error( |
| 4890 | 'You are mounting a new %s component when a previous one has not first unmounted. It is an' + |
| 4891 | ' error to render more than one %s component at a time and attributes and children of these' + |
| 4892 | ' components will likely fail in unpredictable ways. Please only render a single instance of' + |
| 4893 | ' <%s> and if you need to mount a new one, ensure any previous ones have unmounted first.', |
| 4894 | tagName, |
| 4895 | tagName, |
| 4896 | tagName, |
| 4897 | ); |
| 4898 | } |
| 4899 | switch (type) { |
| 4900 | case 'html': |
| 4901 | case 'head': |
| 4902 | case 'body': { |
| 4903 | break; |
| 4904 | } |
| 4905 | default: { |
| 4906 | console.error( |
| 4907 | 'acquireSingletonInstance was called with an element type that is not supported. This is a bug in React.', |
| 4908 | ); |
| 4909 | } |
| 4910 | } |
| 4911 | } |
| 4912 | |
| 4913 | const attributes = instance.attributes; |
| 4914 | while (attributes.length) { |
| 4915 | instance.removeAttributeNode(attributes[0]); |
| 4916 | } |
| 4917 | |
| 4918 | setInitialProperties(instance, type, props); |
| 4919 | precacheFiberNode(internalInstanceHandle, instance); |
| 4920 | updateFiberProps(instance, props); |
| 4921 | } |
| 4922 | |
| 4923 | export function releaseSingletonInstance( |
| 4924 | instance: Instance, |
| 4925 | type: SingletonType, |
| 4926 | props: Props, |
| 4927 | ): void { |
| 4928 | // Remove the attributes and property-backed state owned by this Fiber. |
| 4929 | clearSingletonProperties(instance, type, props); |
| 4930 | |
| 4931 | // These properties aren't cleared by updateProperties when their next |
| 4932 | // value is null. Normally that is handled by replacing/removing the host |
| 4933 | // instance, but a singleton cannot be removed. |
| 4934 | // TODO: HostSingleton updates do not currently schedule ContentReset when |
| 4935 | // dangerouslySetInnerHTML becomes undefined, so an ordinary update can leave |
| 4936 | // the previous HTML in place. This only handles the release path. |
| 4937 | if (props.dangerouslySetInnerHTML != null) { |
| 4938 | instance.textContent = ''; |
| 4939 | } |
| 4940 | clearClickListener(instance as any as HTMLElement); |
| 4941 | |
| 4942 | // Only remove state that was represented by this Fiber's props. Attributes |
| 4943 | // added imperatively while React owned the singleton must be preserved. |
| 4944 | detachDeletedInstance(instance); |
| 4945 | } |
| 4946 | |
| 4947 | function clearSingletonPreambleContribution(instance: Instance): void { |
| 4948 | // This path is only used when clearing a dehydrated boundary that contains a |
| 4949 | // Fizz preamble contribution marker. The marker tells us which singleton the |
| 4950 | // boundary contributed to, but it does not include the contributed props and |
| 4951 | // there is no HostSingleton Fiber to provide them. We therefore cannot tell |
| 4952 | // which attributes came from React and which were added imperatively by a |
| 4953 | // script or third party. For now, clearing every attribute is an accepted |
| 4954 | // edge case. |
| 4955 | // TODO: Include the contributed properties in the marker so this cleanup can |
| 4956 | // remove only the attributes owned by the boundary. |
| 4957 | const attributes = instance.attributes; |
| 4958 | while (attributes.length) { |
| 4959 | instance.removeAttributeNode(attributes[0]); |
| 4960 | } |
| 4961 | detachDeletedInstance(instance); |
| 4962 | } |
| 4963 | |
| 4964 | // ------------------- |
| 4965 | // Resources |
| 4966 | // ------------------- |
| 4967 | |
| 4968 | export const supportsResources = true; |
| 4969 | |
| 4970 | type HoistableTagType = 'link' | 'meta' | 'title'; |
| 4971 | type TResource< |
| 4972 | T: 'stylesheet' | 'style' | 'script' | 'void', |
| 4973 | S: null | {...}, |
| 4974 | > = { |
| 4975 | type: T, |
| 4976 | instance: null | Instance, |
| 4977 | count: number, |
| 4978 | state: S, |
| 4979 | }; |
| 4980 | type StylesheetResource = TResource<'stylesheet', StylesheetState>; |
| 4981 | type StyleTagResource = TResource<'style', null>; |
| 4982 | type StyleResource = StyleTagResource | StylesheetResource; |
| 4983 | type ScriptResource = TResource<'script', null>; |
| 4984 | type VoidResource = TResource<'void', null>; |
| 4985 | export type Resource = StyleResource | ScriptResource | VoidResource; |
| 4986 | |
| 4987 | type LoadingState = number; |
| 4988 | const NotLoaded = /* */ 0b000; |
| 4989 | const Loaded = /* */ 0b001; |
| 4990 | const Errored = /* */ 0b010; |
| 4991 | const Settled = /* */ 0b011; |
| 4992 | const Inserted = /* */ 0b100; |
| 4993 | |
| 4994 | type StylesheetState = { |
| 4995 | loading: LoadingState, |
| 4996 | preload: null | HTMLLinkElement, |
| 4997 | }; |
| 4998 | |
| 4999 | type StyleTagProps = { |
| 5000 | 'data-href': string, |
Showing first 5,000 of 6,891 lines.
View raw