diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 8a3638b01f..688688bcd6 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -1,8 +1,6 @@ import React, { unstable_ViewTransition as ViewTransition, unstable_Activity as Activity, - useRef, - useLayoutEffect, } from 'react'; import './Page.css'; @@ -37,21 +35,17 @@ function Component() { } export default function Page({url, navigate}) { - const ref = useRef(); const show = url === '/?b'; - useLayoutEffect(() => { - const viewTransition = ref.current; - requestAnimationFrame(() => { - const keyframes = [ - {rotate: '0deg', transformOrigin: '30px 8px'}, - {rotate: '360deg', transformOrigin: '30px 8px'}, - ]; - viewTransition.old.animate(keyframes, 300); - viewTransition.new.animate(keyframes, 300); - }); - }, [show]); + function onTransition(viewTransition) { + const keyframes = [ + {rotate: '0deg', transformOrigin: '30px 8px'}, + {rotate: '360deg', transformOrigin: '30px 8px'}, + ]; + viewTransition.old.animate(keyframes, 250); + viewTransition.new.animate(keyframes, 250); + } const exclamation = ( - + ! ); @@ -63,7 +57,7 @@ export default function Page({url, navigate}) { }}> {show ? 'A' : 'B'} - +
{show ? (
@@ -76,7 +70,7 @@ export default function Page({url, navigate}) { {a}
)} - + {show ?
hello{exclamation}
:
Loading
}

scroll me

@@ -98,7 +92,7 @@ export default function Page({url, navigate}) {
!!
- {show ? :

 

} + {show ? : null}
diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 8b4baead09..e57f54e668 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -186,6 +186,7 @@ import { addMarkerIncompleteCallbackToPendingTransition, addMarkerCompleteCallbackToPendingTransition, retryDehydratedSuspenseBoundary, + scheduleViewTransitionEvent, } from './ReactFiberWorkLoop'; import { HasEffect as HookHasEffect, @@ -202,7 +203,10 @@ import { OffscreenDetached, OffscreenPassiveEffectsConnected, } from './ReactFiberActivityComponent'; -import {getViewTransitionName} from './ReactFiberViewTransitionComponent'; +import { + getViewTransitionName, + getViewTransitionClassName, +} from './ReactFiberViewTransitionComponent'; import { TransitionRoot, TransitionTracingMarker, @@ -503,7 +507,7 @@ function commitBeforeMutationEffectsOnFiber( // We should just stash the parent ViewTransitionComponent and continue // walking the tree until we find HostComponent but to do that we need // to use a stack which requires refactoring this phase. - commitBeforeUpdateViewTransition(current); + commitBeforeUpdateViewTransition(current, finishedWork); } } break; @@ -649,6 +653,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { if (child.tag === OffscreenComponent && child.memoizedState === null) { // This tree was already hidden so we skip it. } else { + commitAppearingPairViewTransitions(child); if ( child.tag === ViewTransitionComponent && (child.flags & ViewTransitionNamedStatic) !== NoFlags @@ -661,28 +666,34 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { 'Found a pair with an auto name. This is a bug in React.', ); } - // We found a new appearing view transition with the same name as this deletion. - // We'll transition between them. - viewTransitionHostInstanceIdx = 0; - const inViewport = applyViewTransitionToHostInstances( - child.child, - props.name, + const name = props.name; + const className: ?string = getViewTransitionClassName( props.className, - null, - false, + props.share, ); - if (!inViewport) { - // This boundary is exiting within the viewport but is going to leave the viewport. - // Instead, we treat this as an exit of the previous entry by reverting the new name. - // Ideally we could undo the old transition but it's now too late. It's also on its - // on snapshot. We have know was for it to paint onto the original group. - // TODO: This will lead to things unexpectedly having exit animations that normally - // wouldn't happen. Consider if we should just let this fly off the screen instead. - restoreViewTransitionOnHostInstances(child.child, false); + if (className !== 'none') { + // We found a new appearing view transition with the same name as this deletion. + // We'll transition between them. + viewTransitionHostInstanceIdx = 0; + const inViewport = applyViewTransitionToHostInstances( + child.child, + name, + className, + null, + false, + ); + if (!inViewport) { + // This boundary is exiting within the viewport but is going to leave the viewport. + // Instead, we treat this as an exit of the previous entry by reverting the new name. + // Ideally we could undo the old transition but it's now too late. It's also on its + // on snapshot. We have know was for it to paint onto the original group. + // TODO: This will lead to things unexpectedly having exit animations that normally + // wouldn't happen. Consider if we should just let this fly off the screen instead. + restoreViewTransitionOnHostInstances(child.child, false); + } } } } - commitAppearingPairViewTransitions(child); } child = child.sibling; } @@ -690,21 +701,35 @@ function commitAppearingPairViewTransitions(placement: Fiber): void { function commitEnterViewTransitions(placement: Fiber): void { if (placement.tag === ViewTransitionComponent) { + const state: ViewTransitionState = placement.stateNode; const props: ViewTransitionProps = placement.memoizedProps; - const name = getViewTransitionName(props, placement.stateNode); - viewTransitionHostInstanceIdx = 0; - const inViewport = applyViewTransitionToHostInstances( - placement.child, - name, + const name = getViewTransitionName(props, state); + const className: ?string = getViewTransitionClassName( props.className, - null, - false, + state.paired ? props.share : props.enter, ); - if (!inViewport) { - // Revert the transition names. This boundary is not in the viewport - // so we won't bother animating it. - restoreViewTransitionOnHostInstances(placement.child, false); - // TODO: Should we still visit the children in case a named one was in the viewport? + if (className !== 'none') { + viewTransitionHostInstanceIdx = 0; + const inViewport = applyViewTransitionToHostInstances( + placement.child, + name, + className, + null, + false, + ); + if (!inViewport) { + // TODO: If this was part of a pair we will still run the onShare callback. + // Revert the transition names. This boundary is not in the viewport + // so we won't bother animating it. + restoreViewTransitionOnHostInstances(placement.child, false); + // TODO: Should we still visit the children in case a named one was in the viewport? + } else { + commitAppearingPairViewTransitions(placement); + + if (!state.paired) { + scheduleViewTransitionEvent(placement, props.onEnter); + } + } } else { commitAppearingPairViewTransitions(placement); } @@ -745,25 +770,34 @@ function commitDeletedPairViewTransitions( if (name != null && name !== 'auto') { const pair = appearingViewTransitions.get(name); if (pair !== undefined) { - // We found a new appearing view transition with the same name as this deletion. - viewTransitionHostInstanceIdx = 0; - const inViewport = applyViewTransitionToHostInstances( - child.child, - name, + const className: ?string = getViewTransitionClassName( props.className, - null, - false, + props.share, ); - if (!inViewport) { - // This boundary is not in the viewport so we won't treat it as a matched pair. - // Revert the transition names. This avoids it flying onto the screen which can - // be disruptive and doesn't really preserve any continuity anyway. - restoreViewTransitionOnHostInstances(child.child, false); - } else { - // We'll transition between them. - const oldinstance: ViewTransitionState = child.stateNode; - const newInstance: ViewTransitionState = pair; - newInstance.paired = oldinstance; + if (className !== 'none') { + // We found a new appearing view transition with the same name as this deletion. + viewTransitionHostInstanceIdx = 0; + const inViewport = applyViewTransitionToHostInstances( + child.child, + name, + className, + null, + false, + ); + if (!inViewport) { + // This boundary is not in the viewport so we won't treat it as a matched pair. + // Revert the transition names. This avoids it flying onto the screen which can + // be disruptive and doesn't really preserve any continuity anyway. + restoreViewTransitionOnHostInstances(child.child, false); + } else { + // We'll transition between them. + const oldinstance: ViewTransitionState = child.stateNode; + const newInstance: ViewTransitionState = pair; + newInstance.paired = oldinstance; + // Note: If the other side ends up outside the viewport, we'll still run this. + // Therefore it's possible for onShare to be called with only an old snapshot. + scheduleViewTransitionEvent(child, props.onShare); + } } // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). @@ -787,22 +821,29 @@ function commitExitViewTransitions( if (deletion.tag === ViewTransitionComponent) { const props: ViewTransitionProps = deletion.memoizedProps; const name = getViewTransitionName(props, deletion.stateNode); - viewTransitionHostInstanceIdx = 0; - const inViewport = applyViewTransitionToHostInstances( - deletion.child, - name, + const pair = + appearingViewTransitions !== null + ? appearingViewTransitions.get(name) + : undefined; + const className: ?string = getViewTransitionClassName( props.className, - null, - false, + pair !== undefined ? props.share : props.exit, ); - if (!inViewport) { - // Revert the transition names. This boundary is not in the viewport - // so we won't bother animating it. - restoreViewTransitionOnHostInstances(deletion.child, false); - // TODO: Should we still visit the children in case a named one was in the viewport? - } else if (appearingViewTransitions !== null) { - const pair = appearingViewTransitions.get(name); - if (pair !== undefined) { + if (className !== 'none') { + viewTransitionHostInstanceIdx = 0; + const inViewport = applyViewTransitionToHostInstances( + deletion.child, + name, + className, + null, + false, + ); + if (!inViewport) { + // Revert the transition names. This boundary is not in the viewport + // so we won't bother animating it. + restoreViewTransitionOnHostInstances(deletion.child, false); + // TODO: Should we still visit the children in case a named one was in the viewport? + } else if (pair !== undefined) { // We found a new appearing view transition with the same name as this deletion. // We'll transition between them instead of running the normal exit. const oldinstance: ViewTransitionState = deletion.stateNode; @@ -810,8 +851,16 @@ function commitExitViewTransitions( newInstance.paired = oldinstance; // Delete the entry so that we know when we've found all of them // and can stop searching (size reaches zero). + // $FlowFixMe[incompatible-use]: Refined by the pair. appearingViewTransitions.delete(name); + // Note: If the other side ends up outside the viewport, we'll still run this. + // Therefore it's possible for onShare to be called with only an old snapshot. + scheduleViewTransitionEvent(deletion, props.onShare); + } else { + scheduleViewTransitionEvent(deletion, props.onExit); } + } + if (appearingViewTransitions !== null) { // Look for more pairs deeper in the tree. commitDeletedPairViewTransitions(deletion, appearingViewTransitions); } @@ -828,7 +877,10 @@ function commitExitViewTransitions( } } -function commitBeforeUpdateViewTransition(current: Fiber): void { +function commitBeforeUpdateViewTransition( + current: Fiber, + finishedWork: Fiber, +): void { // The way we deal with multiple HostInstances as children of a View Transition in an // update can get tricky. The important bit is that if you swap out n HostInstances // from n HostInstances then they match up in order. Similarly, if you don't swap @@ -845,13 +897,32 @@ function commitBeforeUpdateViewTransition(current: Fiber): void { // be unexpected but it is in line with the semantics that the ViewTransition is its // own layer that cross-fades its content when it updates. If you want to reorder then // each child needs its own ViewTransition. - const props: ViewTransitionProps = current.memoizedProps; - const name = getViewTransitionName(props, current.stateNode); + const oldProps: ViewTransitionProps = current.memoizedProps; + const oldName = getViewTransitionName(oldProps, current.stateNode); + const newProps: ViewTransitionProps = finishedWork.memoizedProps; + // This className applies only if there are fewer child DOM nodes than + // before or if this update should've been cancelled but we ended up with + // a parent animating so we need to animate the child too. + // For example, if update="foo" layout="none" and it turns out this was + // a layout only change, then the "foo" class will be applied even though + // it was not actually an update. Which is a bug. + let className: ?string = getViewTransitionClassName( + newProps.className, + newProps.update, + ); + if (className === 'none') { + className = getViewTransitionClassName(newProps.className, newProps.layout); + if (className === 'none') { + // If both update and layout are both "none" then we don't have to + // apply a name. Since we won't animate this boundary. + return; + } + } viewTransitionHostInstanceIdx = 0; applyViewTransitionToHostInstances( current.child, - name, - props.className, + oldName, + className, (current.memoizedState = []), true, ); @@ -865,14 +936,20 @@ function commitNestedViewTransitions(changedParent: Fiber): void { // was an update through this component then the inner one wins. const props: ViewTransitionProps = child.memoizedProps; const name = getViewTransitionName(props, child.stateNode); - viewTransitionHostInstanceIdx = 0; - applyViewTransitionToHostInstances( - child.child, - name, + const className: ?string = getViewTransitionClassName( props.className, - (child.memoizedState = []), - false, + props.layout, ); + if (className !== 'none') { + viewTransitionHostInstanceIdx = 0; + applyViewTransitionToHostInstances( + child.child, + name, + className, + (child.memoizedState = []), + false, + ); + } } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) { commitNestedViewTransitions(child); } @@ -962,10 +1039,58 @@ function restoreNestedViewTransitions(changedParent: Fiber): void { } } +function cancelViewTransitionHostInstances( + currentViewTransition: Fiber, + child: null | Fiber, + stopAtNestedViewTransitions: boolean, +): void { + if (!supportsMutation) { + return; + } + while (child !== null) { + if (child.tag === HostComponent) { + const instance: Instance = child.stateNode; + const oldName = getViewTransitionName( + currentViewTransition.memoizedProps, + currentViewTransition.stateNode, + ); + if (viewTransitionCancelableChildren === null) { + viewTransitionCancelableChildren = []; + } + viewTransitionCancelableChildren.push( + instance, + oldName, + child.memoizedProps, + ); + viewTransitionHostInstanceIdx++; + } else if ( + child.tag === OffscreenComponent && + child.memoizedState !== null + ) { + // Skip any hidden subtrees. They were or are effectively not there. + } else if ( + child.tag === ViewTransitionComponent && + stopAtNestedViewTransitions + ) { + // Skip any nested view transitions for updates since in that case the + // inner most one is the one that handles the update. + } else { + cancelViewTransitionHostInstances( + currentViewTransition, + child.child, + stopAtNestedViewTransitions, + ); + } + child = child.sibling; + } +} + function measureViewTransitionHostInstances( currentViewTransition: Fiber, parentViewTransition: Fiber, child: null | Fiber, + name: string, + className: ?string, previousMeasurements: null | Array, stopAtNestedViewTransitions: boolean, ): boolean { @@ -1011,20 +1136,15 @@ function measureViewTransitionHostInstances( parentViewTransition.flags |= AffectedParentLayout; } if ((parentViewTransition.flags & Update) !== NoFlags) { - const props: ViewTransitionProps = parentViewTransition.memoizedProps; // We might update this node so we need to apply its new name for the new state. - const newName = getViewTransitionName( - props, - parentViewTransition.stateNode, - ); applyViewTransitionName( instance, viewTransitionHostInstanceIdx === 0 - ? newName + ? name : // If we have multiple Host Instances below, we add a suffix to the name to give // each one a unique name. - newName + '_' + viewTransitionHostInstanceIdx, - props.className, + name + '_' + viewTransitionHostInstanceIdx, + className, ); } if (!inViewport || (parentViewTransition.flags & Update) === NoFlags) { @@ -1066,6 +1186,8 @@ function measureViewTransitionHostInstances( currentViewTransition, parentViewTransition, child.child, + name, + className, previousMeasurements, stopAtNestedViewTransitions, ) @@ -1082,6 +1204,42 @@ function measureUpdateViewTransition( current: Fiber, finishedWork: Fiber, ): boolean { + const props: ViewTransitionProps = finishedWork.memoizedProps; + const updateClassName: ?string = getViewTransitionClassName( + props.className, + props.update, + ); + const layoutClassName: ?string = getViewTransitionClassName( + props.className, + props.update, + ); + let className: ?string; + if (updateClassName === 'none') { + if (layoutClassName === 'none') { + // If both update and layout class name were none, then we didn't apply any + // names in the before update phase so we shouldn't now neither. + return false; + } + // We don't care if this is mutated or children layout changed, but we still + // measure each instance to see if it moved and therefore should apply layout. + finishedWork.flags &= ~Update; + className = layoutClassName; + } else if ((finishedWork.flags & Update) !== NoFlags) { + // It was updated and we have an appropriate class name to apply. + className = updateClassName; + } else { + if (layoutClassName === 'none') { + // If we did not update, then all changes are considered a layout. We'll + // attempt to cancel. + viewTransitionHostInstanceIdx = 0; + cancelViewTransitionHostInstances(current, finishedWork.child, true); + return false; + } + // We didn't update but we might still apply layout so we measure each + // instance to see if it moved or resized. + className = layoutClassName; + } + const name = getViewTransitionName(props, finishedWork.stateNode); // If nothing changed due to a mutation, or children changing size // and the measurements end up unchanged, we should restore it to not animate. viewTransitionHostInstanceIdx = 0; @@ -1090,6 +1248,8 @@ function measureUpdateViewTransition( current, finishedWork, finishedWork.child, + name, + className, previousMeasurements, true, ); @@ -1110,14 +1270,27 @@ function measureNestedViewTransitions(changedParent: Fiber): void { if (child.tag === ViewTransitionComponent) { const current = child.alternate; if (current !== null) { + const props: ViewTransitionProps = child.memoizedProps; + const name = getViewTransitionName(props, child.stateNode); + const className: ?string = getViewTransitionClassName( + props.className, + props.layout, + ); viewTransitionHostInstanceIdx = 0; - measureViewTransitionHostInstances( + const inViewport = measureViewTransitionHostInstances( current, child, child.child, + name, + className, child.memoizedState, false, ); + if ((child.flags & Update) === NoFlags || !inViewport) { + // Nothing changed. + } else { + scheduleViewTransitionEvent(child, props.onLayout); + } } } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) { measureNestedViewTransitions(child); @@ -2991,11 +3164,6 @@ function recursivelyTraverseAfterMutationEffects( // its size and position. We need to measure this and if not, restore it to // not animate. measureNestedViewTransitions(parentFiber); - if ((parentFiber.flags & AffectedParentLayout) !== NoFlags) { - // This boundary changed size in a way that may have caused its parent to - // relayout. We need to bubble this information up to the parent. - viewTransitionContextChanged = true; - } } } @@ -3075,6 +3243,8 @@ function commitAfterMutationEffectsOnFiber( (Placement | Update | ChildDeletion | ContentReset | Visibility)) !== NoFlags ) { + const wasMutated = (finishedWork.flags & Update) !== NoFlags; + const prevContextChanged = viewTransitionContextChanged; const prevCancelableChildren = viewTransitionCancelableChildren; viewTransitionContextChanged = false; @@ -3103,7 +3273,17 @@ function commitAfterMutationEffectsOnFiber( ); viewTransitionCancelableChildren = prevCancelableChildren; } + // TODO: If this doesn't end up canceled, because a parent animates, + // then we should probably issue an event since this instance is part of it. } else { + const props: ViewTransitionProps = finishedWork.memoizedProps; + scheduleViewTransitionEvent( + finishedWork, + wasMutated || viewTransitionContextChanged + ? props.onUpdate + : props.onLayout, + ); + // If this boundary did update, we cannot cancel its children so those are dropped. viewTransitionCancelableChildren = prevCancelableChildren; } diff --git a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js index 008a6992f2..65e3ffc459 100644 --- a/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js +++ b/packages/react-reconciler/src/ReactFiberViewTransitionComponent.js @@ -19,8 +19,18 @@ import {getTreeId} from './ReactFiberTreeContext'; export type ViewTransitionProps = { name?: string, - className?: string, children?: ReactNodeList, + className?: 'none' | string, + enter?: 'none' | string, + exit?: 'none' | string, + layout?: 'none' | string, + share?: 'none' | string, + update?: 'none' | string, + onEnter?: (instance: ViewTransitionInstance) => void, + onExit?: (instance: ViewTransitionInstance) => void, + onLayout?: (instance: ViewTransitionInstance) => void, + onShare?: (instance: ViewTransitionInstance) => void, + onUpdate?: (instance: ViewTransitionInstance) => void, }; export type ViewTransitionState = { @@ -71,3 +81,19 @@ export function getViewTransitionName( // We should have assigned a name by now. return (instance.autoName: any); } + +export function getViewTransitionClassName( + className: ?string, + eventClassName: ?string, +): ?string { + if (eventClassName == null) { + return className; + } + if (eventClassName === 'none') { + return eventClassName; + } + if (className != null) { + return className + ' ' + eventClassName; + } + return eventClassName; +} diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f3ba210bf9..43cba4586f 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -21,9 +21,12 @@ import type { TransitionAbort, } from './ReactFiberTracingMarkerComponent'; import type {OffscreenInstance} from './ReactFiberActivityComponent'; -import type {Resource} from './ReactFiberConfig'; +import type {Resource, ViewTransitionInstance} from './ReactFiberConfig'; import type {RootState} from './ReactFiberRoot'; -import type {ViewTransitionState} from './ReactFiberViewTransitionComponent'; +import { + getViewTransitionName, + type ViewTransitionState, +} from './ReactFiberViewTransitionComponent'; import { enableCreateEventHandleAPI, @@ -95,6 +98,7 @@ import { resolveUpdatePriority, trackSchedulerEvent, startViewTransition, + createViewTransitionInstance, } from './ReactFiberConfig'; import {createWorkInProgress, resetWorkInProgress} from './ReactFiber'; @@ -649,6 +653,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes; let pendingEffectsRenderEndTime: number = -0; // Profiling-only let pendingPassiveTransitions: Array | null = null; let pendingRecoverableErrors: null | Array> = null; +let pendingViewTransitionEvents: Array<() => void> | null = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only @@ -797,6 +802,27 @@ export function requestDeferredLane(): Lane { return workInProgressDeferredLane; } +export function scheduleViewTransitionEvent( + fiber: Fiber, + callback: ?(instance: ViewTransitionInstance) => void, +): void { + if (enableViewTransition) { + if (callback != null) { + const state: ViewTransitionState = fiber.stateNode; + let instance = state.ref; + if (instance === null) { + instance = state.ref = createViewTransitionInstance( + getViewTransitionName(fiber.memoizedProps, state), + ); + } + if (pendingViewTransitionEvents === null) { + pendingViewTransitionEvents = []; + } + pendingViewTransitionEvents.push(callback.bind(null, instance)); + } + } +} + export function peekDeferredLane(): Lane { return workInProgressDeferredLane; } @@ -3322,6 +3348,9 @@ function commitRoot( pendingEffectsRemainingLanes = remainingLanes; pendingPassiveTransitions = transitions; pendingRecoverableErrors = recoverableErrors; + if (enableViewTransition) { + pendingViewTransitionEvents = null; + } pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; if (enableProfilerTimer) { pendingEffectsRenderEndTime = completedRenderEndTime; @@ -3673,6 +3702,21 @@ function flushSpawnedWork(): void { } } + if (enableViewTransition) { + // We should now be after the startViewTransition's .ready call which is late enough + // to start animating any pseudo-elements. We do this before flushing any passive + // effects or spawned sync work since this is still part of the previous commit. + // Even though conceptually it's like its own task between layout effets and passive. + const pendingEvents = pendingViewTransitionEvents; + if (pendingEvents !== null) { + pendingViewTransitionEvents = null; + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + viewTransitionEvent(); + } + } + } + // If the passive effects are the result of a discrete render, flush them // synchronously at the end of the current task so that the result is // immediately observable. Otherwise, we assume that they are not