@samitouri / QOS-React-1 / commits / 65b5aae010

[Fizz] Add vt- prefix attributes to annotate <ViewTransition> in HTML (#33206)

Stacked on #33194 and #33200. When Suspense boundaries reveal during streaming, the Fizz runtime will be responsible for animating the reveal if necessary (not in this PR). However, for the future runtime to know what to do it needs to know about the `<ViewTransition>` configuration to apply. Ofc, these are virtual nodes that disappear from the HTML. We could model them as comments like we do with other virtual nodes like Suspense and Activity. However, that doesn't let us target them with querySelector and CSS (for no-JS transitions). We also don't have to model every ViewTransition since not every combination can happen using only the server runtime. So instead this collapses `<ViewTransition>` and applies the configuration to the inner DOM nodes. ```js <ViewTransition name="hi"> <div /> <div /> </ViewTransition> ``` Becomes: ```html <div vt-name="hi" vt-update="auto"></div> <div vt-name="hi_1" vt-update="auto"></div> ``` I use `vt-` prefix as opposed to `data-` to keep these virtual attributes away from user specific ones but we're effectively claiming this namespace. There are four triggers `vt-update`, `vt-enter`, `vt-exit` and `vt-share`. The server resolves which ones might apply to this DOM node. The value represents the class name (after resolving view-transition-type mappings) or `"auto"` if no specific class name is needed but this is still a trigger. The value can also be `"none"`. This is different from missing because for example an `vt-update="none"` will block mutations inside it from triggering the boundary where as a missing `vt-update` would bubble up to be handled by a parent. `vt-name` is technically only necessary when `vt-share` is specified to find a pair. However, since an explicit name can also be used to target specific CSS selectors, we include it even for other cases. We want to exclude as many of these annotations as possible. `vt-enter` can only affect the first DOM node inside a Suspense boundary's content since the reveal would cause it to enter but nothing deeper inside. Similarly `vt-exit` can only affect the first DOM node inside a fallback. So for every other case we can exclude them. (For future MPA ViewTransitions of the whole document it might also be something we annotate to children inside the `<body>` as well.) Ideally we'd only include `vt-enter` for Suspense boundaries that actually flushed a fallback but since we prepare all that content earlier it's hard to know. `vt-share` can be anywhere inside an fallback or content. Technically we don't have to include it outside the root most Suspense boundary or for boundaries that are inlined into the root shell. However, this is tricky to detect. It would also not be correct for future MPA ViewTransitions because in that case the shared scenario can affect anything in the two documents so it needs to be in every node everywhere which is effectively what we do. If a `share` class is specified but it has no explicit name, we can exclude it since it can't match anything. `vt-update` is only necessary if something below or a sibling might update like a Suspense boundary. However, since we don't know when rendering a segment if it'll later asynchronously add a Suspense boundary later we have to assume that anywhere might have a child. So these are always included. We collapse to use the inner most one when directly nested though since that's the one that ends up winning. There are some weird edge cases that can't be fully modeled by the lack of virtual nodes.

Sebastian Markbåge committed May 15, 2025 at 01:04 UTC 65b5aae010002ef88221cc4998711eaef6068006
9 files changed +797 -60
packages/react-dom-bindings/src/client/ReactDOMComponent.js
+13
@@ -72,6 +72,7 @@ import {
72 enableScrollEndPolyfill,
73 enableSrcObject,
74 enableTrustedTypesIntegration,
75 + enableViewTransition,
76 } from 'shared/ReactFeatureFlags';
77 import {
78 mediaEventTypes,
@@ -3217,6 +3218,18 @@ export function diffHydratedProperties(
3218 break;
3219 case 'selected':
3220 break;
3221 + case 'vt-name':
3222 + case 'vt-update':
3223 + case 'vt-enter':
3224 + case 'vt-exit':
3225 + case 'vt-share':
3226 + if (enableViewTransition) {
3227 + // View Transition annotations are expected from the Server Runtime.
3228 + // However, if they're also specified on the client and don't match
3229 + // that's an error.
3230 + break;
3231 + }
3232 + // Fallthrough
3233 default:
3234 // Intentionally use the original name.
3235 // See discussion in https://github.com/facebook/react/pull/10676.
packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js
+322 -60
@@ -35,6 +35,7 @@ import {
35 enableFizzExternalRuntime,
36 enableSrcObject,
37 enableFizzBlockingRender,
38 + enableViewTransition,
39 } from 'shared/ReactFeatureFlags';
40
41 import type {
@@ -741,27 +742,47 @@ const HTML_COLGROUP_MODE = 9;
742
743 type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
744
744 -const NO_SCOPE = /* */ 0b000;
745 -const NOSCRIPT_SCOPE = /* */ 0b001;
746 -const PICTURE_SCOPE = /* */ 0b010;
747 -const FALLBACK_SCOPE = /* */ 0b100;
745 +const NO_SCOPE = /* */ 0b00000;
746 +const NOSCRIPT_SCOPE = /* */ 0b00001;
747 +const PICTURE_SCOPE = /* */ 0b00010;
748 +const FALLBACK_SCOPE = /* */ 0b00100;
749 +const EXIT_SCOPE = /* */ 0b01000; // A direct Instance below a Suspense fallback is the only thing that can "exit"
750 +const ENTER_SCOPE = /* */ 0b10000; // A direct Instance below Suspense content is the only thing that can "enter"
751 +
752 +// Everything not listed here are tracked for the whole subtree as opposed to just
753 +// until the next Instance.
754 +const SUBTREE_SCOPE = ~(ENTER_SCOPE | EXIT_SCOPE);
755 +
756 +type ViewTransitionContext = {
757 + update: 'none' | 'auto' | string,
758 + // null here means that this case can never trigger. Not "auto" like it does in props.
759 + enter: null | 'none' | 'auto' | string,
760 + exit: null | 'none' | 'auto' | string,
761 + share: null | 'none' | 'auto' | string,
762 + name: 'auto' | string,
763 + autoName: string, // a name that can be used if an explicit one is not defined.
764 + nameIdx: number, // keeps track of how many duplicates of this name we've emitted.
765 +};
766
767 // Lets us keep track of contextual state and pick it back up after suspending.
768 export type FormatContext = {
769 insertionMode: InsertionMode, // root/svg/html/mathml/table
770 selectedValue: null | string | Array<string>, // the selected value(s) inside a <select>, or null outside <select>
771 tagScope: number,
772 + viewTransition: null | ViewTransitionContext, // tracks if we're inside a ViewTransition outside the first DOM node
773 };
774
775 function createFormatContext(
776 insertionMode: InsertionMode,
777 selectedValue: null | string | Array<string>,
778 tagScope: number,
779 + viewTransition: null | ViewTransitionContext,
780 ): FormatContext {
781 return {
782 insertionMode,
783 selectedValue,
784 tagScope,
785 + viewTransition,
786 };
787 }
788
@@ -776,7 +797,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext {
797 : namespaceURI === 'http://www.w3.org/1998/Math/MathML'
798 ? MATHML_MODE
799 : ROOT_HTML_MODE;
779 - return createFormatContext(insertionMode, null, NO_SCOPE);
800 + return createFormatContext(insertionMode, null, NO_SCOPE, null);
801 }
802
803 export function getChildFormatContext(
@@ -784,101 +805,209 @@ export function getChildFormatContext(
805 type: string,
806 props: Object,
807 ): FormatContext {
808 + const subtreeScope = parentContext.tagScope & SUBTREE_SCOPE;
809 switch (type) {
810 case 'noscript':
811 return createFormatContext(
812 HTML_MODE,
813 null,
792 - parentContext.tagScope | NOSCRIPT_SCOPE,
814 + subtreeScope | NOSCRIPT_SCOPE,
815 + null,
816 );
817 case 'select':
818 return createFormatContext(
819 HTML_MODE,
820 props.value != null ? props.value : props.defaultValue,
798 - parentContext.tagScope,
821 + subtreeScope,
822 + null,
823 );
824 case 'svg':
801 - return createFormatContext(SVG_MODE, null, parentContext.tagScope);
825 + return createFormatContext(SVG_MODE, null, subtreeScope, null);
826 case 'picture':
827 return createFormatContext(
828 HTML_MODE,
829 null,
806 - parentContext.tagScope | PICTURE_SCOPE,
830 + subtreeScope | PICTURE_SCOPE,
831 + null,
832 );
833 case 'math':
809 - return createFormatContext(MATHML_MODE, null, parentContext.tagScope);
834 + return createFormatContext(MATHML_MODE, null, subtreeScope, null);
835 case 'foreignObject':
811 - return createFormatContext(HTML_MODE, null, parentContext.tagScope);
836 + return createFormatContext(HTML_MODE, null, subtreeScope, null);
837 // Table parents are special in that their children can only be created at all if they're
838 // wrapped in a table parent. So we need to encode that we're entering this mode.
839 case 'table':
815 - return createFormatContext(HTML_TABLE_MODE, null, parentContext.tagScope);
840 + return createFormatContext(HTML_TABLE_MODE, null, subtreeScope, null);
841 case 'thead':
842 case 'tbody':
843 case 'tfoot':
844 return createFormatContext(
845 HTML_TABLE_BODY_MODE,
846 null,
822 - parentContext.tagScope,
823 - );
824 - case 'colgroup':
825 - return createFormatContext(
826 - HTML_COLGROUP_MODE,
847 + subtreeScope,
848 null,
828 - parentContext.tagScope,
849 );
850 + case 'colgroup':
851 + return createFormatContext(HTML_COLGROUP_MODE, null, subtreeScope, null);
852 case 'tr':
831 - return createFormatContext(
832 - HTML_TABLE_ROW_MODE,
833 - null,
834 - parentContext.tagScope,
835 - );
853 + return createFormatContext(HTML_TABLE_ROW_MODE, null, subtreeScope, null);
854 case 'head':
855 if (parentContext.insertionMode < HTML_MODE) {
856 // We are either at the root or inside the <html> tag and can enter
857 // the <head> scope
840 - return createFormatContext(
841 - HTML_HEAD_MODE,
842 - null,
843 - parentContext.tagScope,
844 - );
858 + return createFormatContext(HTML_HEAD_MODE, null, subtreeScope, null);
859 }
860 break;
861 case 'html':
862 if (parentContext.insertionMode === ROOT_HTML_MODE) {
849 - return createFormatContext(
850 - HTML_HTML_MODE,
851 - null,
852 - parentContext.tagScope,
853 - );
863 + return createFormatContext(HTML_HTML_MODE, null, subtreeScope, null);
864 }
865 break;
866 }
867 if (parentContext.insertionMode >= HTML_TABLE_MODE) {
868 // Whatever tag this was, it wasn't a table parent or other special parent, so we must have
869 // entered plain HTML again.
860 - return createFormatContext(HTML_MODE, null, parentContext.tagScope);
870 + return createFormatContext(HTML_MODE, null, subtreeScope, null);
871 }
872 if (parentContext.insertionMode < HTML_MODE) {
863 - return createFormatContext(HTML_MODE, null, parentContext.tagScope);
873 + return createFormatContext(HTML_MODE, null, subtreeScope, null);
874 + }
875 + if (enableViewTransition) {
876 + if (parentContext.viewTransition !== null) {
877 + // If we're inside a view transition, regardless what element we were in, it consumes
878 + // the view transition context.
879 + return createFormatContext(
880 + parentContext.insertionMode,
881 + parentContext.selectedValue,
882 + subtreeScope,
883 + null,
884 + );
885 + }
886 + }
887 + if (parentContext.tagScope !== subtreeScope) {
888 + return createFormatContext(
889 + parentContext.insertionMode,
890 + parentContext.selectedValue,
891 + subtreeScope,
892 + null,
893 + );
894 }
895 return parentContext;
896 }
897
898 +function getSuspenseViewTransition(
899 + parentViewTransition: null | ViewTransitionContext,
900 +): null | ViewTransitionContext {
901 + if (parentViewTransition === null) {
902 + return null;
903 + }
904 + // If a ViewTransition wraps a Suspense boundary it applies to the children Instances
905 + // in both the fallback and the content.
906 + // Since we only have a representation of ViewTransitions on the Instances themselves
907 + // we cannot model the parent ViewTransition activating "enter", "exit" or "share"
908 + // since those would be ambiguous with the Suspense boundary changing states and
909 + // affecting the same Instances.
910 + // We also can't model an "update" when that update is fallback nodes swapping for
911 + // content nodes. However, we can model is as a "share" from the fallback nodes to
912 + // the content nodes using the same name. We just have to assign the same name that
913 + // we would've used (the parent ViewTransition name or auto-assign one).
914 + const viewTransition: ViewTransitionContext = {
915 + update: parentViewTransition.update, // For deep updates.
916 + enter: null,
917 + exit: null,
918 + share: parentViewTransition.update, // For exit or enter of reveals.
919 + name: parentViewTransition.autoName,
920 + autoName: parentViewTransition.autoName,
921 + // TOOD: If we have more than just this Suspense boundary as a child of the ViewTransition
922 + // then the parent needs to isolate the names so that they don't conflict.
923 + nameIdx: 0,
924 + };
925 + return viewTransition;
926 +}
927 +
928 export function getSuspenseFallbackFormatContext(
929 parentContext: FormatContext,
930 ): FormatContext {
931 return createFormatContext(
932 parentContext.insertionMode,
933 parentContext.selectedValue,
874 - parentContext.tagScope | FALLBACK_SCOPE,
934 + parentContext.tagScope | FALLBACK_SCOPE | EXIT_SCOPE,
935 + getSuspenseViewTransition(parentContext.viewTransition),
936 );
937 }
938
939 export function getSuspenseContentFormatContext(
940 parentContext: FormatContext,
941 ): FormatContext {
881 - return parentContext;
942 + return createFormatContext(
943 + parentContext.insertionMode,
944 + parentContext.selectedValue,
945 + parentContext.tagScope | ENTER_SCOPE,
946 + getSuspenseViewTransition(parentContext.viewTransition),
947 + );
948 +}
949 +
950 +export function getViewTransitionFormatContext(
951 + parentContext: FormatContext,
952 + update: ?string,
953 + enter: ?string,
954 + exit: ?string,
955 + share: ?string,
956 + name: ?string,
957 + autoName: string, // name or an autogenerated unique name
958 +): FormatContext {
959 + // We're entering a <ViewTransition>. Normalize props.
960 + if (update == null) {
961 + update = 'auto';
962 + }
963 + if (enter == null) {
964 + enter = 'auto';
965 + }
966 + if (exit == null) {
967 + exit = 'auto';
968 + }
969 + if (name == null) {
970 + const parentViewTransition = parentContext.viewTransition;
971 + if (parentViewTransition !== null) {
972 + // If we have multiple nested ViewTransition and the parent has a "share"
973 + // but the child doesn't, then the parent ViewTransition can still activate
974 + // a share scenario so we reuse the name and share from the parent.
975 + name = parentViewTransition.name;
976 + share = parentViewTransition.share;
977 + } else {
978 + name = 'auto';
979 + share = null; // share is only relevant if there's an explicit name
980 + }
981 + } else if (share === 'none') {
982 + // I believe if share is disabled, it means the same thing as if it doesn't
983 + // exit because enter/exit will take precedence and if it's deeply nested
984 + // it just animates along whatever the parent does when disabled.
985 + share = null;
986 + } else if (share == null) {
987 + share = 'auto';
988 + }
989 + if (!(parentContext.tagScope & EXIT_SCOPE)) {
990 + exit = null; // exit is only relevant for the first ViewTransition inside fallback
991 + }
992 + if (!(parentContext.tagScope & ENTER_SCOPE)) {
993 + enter = null; // enter is only relevant for the first ViewTransition inside content
994 + }
995 + const viewTransition: ViewTransitionContext = {
996 + update,
997 + enter,
998 + exit,
999 + share,
1000 + name,
1001 + autoName,
1002 + nameIdx: 0,
1003 + };
1004 + const subtreeScope = parentContext.tagScope & SUBTREE_SCOPE;
1005 + return createFormatContext(
1006 + parentContext.insertionMode,
1007 + parentContext.selectedValue,
1008 + subtreeScope,
1009 + viewTransition,
1010 + );
1011 }
1012
1013 export function isPreambleContext(formatContext: FormatContext): boolean {
@@ -940,6 +1069,43 @@ export function pushSegmentFinale(
1069 }
1070 }
1071
1072 +function pushViewTransitionAttributes(
1073 + target: Array<Chunk | PrecomputedChunk>,
1074 + formatContext: FormatContext,
1075 +): void {
1076 + if (!enableViewTransition) {
1077 + return;
1078 + }
1079 + const viewTransition = formatContext.viewTransition;
1080 + if (viewTransition === null) {
1081 + return;
1082 + }
1083 + if (viewTransition.name !== 'auto') {
1084 + pushStringAttribute(
1085 + target,
1086 + 'vt-name',
1087 + viewTransition.nameIdx === 0
1088 + ? viewTransition.name
1089 + : viewTransition.name + '_' + viewTransition.nameIdx,
1090 + );
1091 + // Increment the index in case we have multiple children to the same ViewTransition.
1092 + // Because this is a side-effect in render, we should ideally call pushViewTransitionAttributes
1093 + // after we've suspended (like forms do), so that we don't increment each attempt.
1094 + // TODO: Make this deterministic.
1095 + viewTransition.nameIdx++;
1096 + }
1097 + pushStringAttribute(target, 'vt-update', viewTransition.update);
1098 + if (viewTransition.enter !== null) {
1099 + pushStringAttribute(target, 'vt-enter', viewTransition.enter);
1100 + }
1101 + if (viewTransition.exit !== null) {
1102 + pushStringAttribute(target, 'vt-exit', viewTransition.exit);
1103 + }
1104 + if (viewTransition.share !== null) {
1105 + pushStringAttribute(target, 'vt-share', viewTransition.share);
1106 + }
1107 +}
1108 +
1109 const styleNameCache: Map<string, PrecomputedChunk> = new Map();
1110 function processStyleName(styleName: string): PrecomputedChunk {
1111 const chunk = styleNameCache.get(styleName);
@@ -1072,6 +1238,7 @@ function pushStringAttribute(
1238 }
1239
1240 function makeFormFieldPrefix(resumableState: ResumableState): string {
1241 + // TODO: Make this deterministic.
1242 const id = resumableState.nextFormID++;
1243 return resumableState.idPrefix + id;
1244 }
@@ -1678,6 +1845,7 @@ function checkSelectProp(props: any, propName: string) {
1845 function pushStartAnchor(
1846 target: Array<Chunk | PrecomputedChunk>,
1847 props: Object,
1848 + formatContext: FormatContext,
1849 ): ReactNodeList {
1850 target.push(startChunkForTag('a'));
1851
@@ -1712,6 +1880,8 @@ function pushStartAnchor(
1880 }
1881 }
1882
1883 + pushViewTransitionAttributes(target, formatContext);
1884 +
1885 target.push(endOfStartTag);
1886 pushInnerHTML(target, innerHTML, children);
1887 if (typeof children === 'string') {
@@ -1726,6 +1896,7 @@ function pushStartAnchor(
1896 function pushStartObject(
1897 target: Array<Chunk | PrecomputedChunk>,
1898 props: Object,
1899 + formatContext: FormatContext,
1900 ): ReactNodeList {
1901 target.push(startChunkForTag('object'));
1902
@@ -1777,6 +1948,8 @@ function pushStartObject(
1948 }
1949 }
1950
1951 + pushViewTransitionAttributes(target, formatContext);
1952 +
1953 target.push(endOfStartTag);
1954 pushInnerHTML(target, innerHTML, children);
1955 if (typeof children === 'string') {
@@ -1791,6 +1964,7 @@ function pushStartObject(
1964 function pushStartSelect(
1965 target: Array<Chunk | PrecomputedChunk>,
1966 props: Object,
1967 + formatContext: FormatContext,
1968 ): ReactNodeList {
1969 if (__DEV__) {
1970 checkControlledValueProps('select', props);
@@ -1844,6 +2018,8 @@ function pushStartSelect(
2018 }
2019 }
2020
2021 + pushViewTransitionAttributes(target, formatContext);
2022 +
2023 target.push(endOfStartTag);
2024 pushInnerHTML(target, innerHTML, children);
2025 return children;
@@ -1973,6 +2149,7 @@ function pushStartOption(
2149 target.push(selectedMarkerAttribute);
2150 }
2151
2152 + // Options never participate as ViewTransitions.
2153 target.push(endOfStartTag);
2154 pushInnerHTML(target, innerHTML, children);
2155 return children;
@@ -2042,6 +2219,7 @@ function pushStartForm(
2219 props: Object,
2220 resumableState: ResumableState,
2221 renderState: RenderState,
2222 + formatContext: FormatContext,
2223 ): ReactNodeList {
2224 target.push(startChunkForTag('form'));
2225
@@ -2151,6 +2329,8 @@ function pushStartForm(
2329 pushAttribute(target, 'target', formTarget);
2330 }
2331
2332 + pushViewTransitionAttributes(target, formatContext);
2333 +
2334 target.push(endOfStartTag);
2335
2336 if (formActionName !== null) {
@@ -2175,6 +2355,7 @@ function pushInput(
2355 props: Object,
2356 resumableState: ResumableState,
2357 renderState: RenderState,
2358 + formatContext: FormatContext,
2359 ): ReactNodeList {
2360 if (__DEV__) {
2361 checkControlledValueProps('input', props);
@@ -2304,6 +2485,8 @@ function pushInput(
2485 pushAttribute(target, 'value', defaultValue);
2486 }
2487
2488 + pushViewTransitionAttributes(target, formatContext);
2489 +
2490 target.push(endOfStartTagSelfClosing);
2491
2492 // We place any additional hidden form fields after the input.
@@ -2317,6 +2500,7 @@ function pushStartButton(
2500 props: Object,
2501 resumableState: ResumableState,
2502 renderState: RenderState,
2503 + formatContext: FormatContext,
2504 ): ReactNodeList {
2505 target.push(startChunkForTag('button'));
2506
@@ -2388,6 +2572,8 @@ function pushStartButton(
2572 name,
2573 );
2574
2575 + pushViewTransitionAttributes(target, formatContext);
2576 +
2577 target.push(endOfStartTag);
2578
2579 // We place any additional hidden form fields we need to include inside the button itself.
@@ -2407,6 +2593,7 @@ function pushStartButton(
2593 function pushStartTextArea(
2594 target: Array<Chunk | PrecomputedChunk>,
2595 props: Object,
2596 + formatContext: FormatContext,
2597 ): ReactNodeList {
2598 if (__DEV__) {
2599 checkControlledValueProps('textarea', props);
@@ -2461,6 +2648,8 @@ function pushStartTextArea(
2648 value = defaultValue;
2649 }
2650
2651 + pushViewTransitionAttributes(target, formatContext);
2652 +
2653 target.push(endOfStartTag);
2654
2655 // TODO (yungsters): Remove support for children content in <textarea>.
@@ -2537,7 +2726,7 @@ function pushMeta(
2726 noscriptTagInScope ||
2727 props.itemProp != null
2728 ) {
2540 - return pushSelfClosing(target, props, 'meta');
2729 + return pushSelfClosing(target, props, 'meta', formatContext);
2730 } else {
2731 if (textEmbedded) {
2732 // This link follows text but we aren't writing a tag. while not as efficient as possible we need
@@ -2556,15 +2745,30 @@ function pushMeta(
2745 // the only way to embed the tag today we flush it on a special queue on the Request so it
2746 // can go before everything else. Like viewport this means that the tag will escape it's
2747 // parent container.
2559 - return pushSelfClosing(renderState.charsetChunks, props, 'meta');
2748 + return pushSelfClosing(
2749 + renderState.charsetChunks,
2750 + props,
2751 + 'meta',
2752 + formatContext,
2753 + );
2754 } else if (props.name === 'viewport') {
2755 // "viewport" is flushed on the Request so it can go earlier that Float resources that
2756 // might be affected by it. This means it can escape the boundary it is rendered within.
2757 // This is a pragmatic solution to viewport being incredibly sensitive to document order
2758 // without requiring all hoistables to be flushed too early.
2565 - return pushSelfClosing(renderState.viewportChunks, props, 'meta');
2759 + return pushSelfClosing(
2760 + renderState.viewportChunks,
2761 + props,
2762 + 'meta',
2763 + formatContext,
2764 + );
2765 } else {
2567 - return pushSelfClosing(renderState.hoistableChunks, props, 'meta');
2766 + return pushSelfClosing(
2767 + renderState.hoistableChunks,
2768 + props,
2769 + 'meta',
2770 + formatContext,
2771 + );
2772 }
2773 }
2774 }
@@ -2771,6 +2975,8 @@ function pushLinkImpl(
2975 }
2976 }
2977
2978 + // Link never participate as a ViewTransition
2979 +
2980 target.push(endOfStartTagSelfClosing);
2981 return null;
2982 }
@@ -2936,6 +3142,8 @@ function pushStyleImpl(
3142 }
3143 }
3144 }
3145 +
3146 + // Style never participate as a ViewTransition.
3147 target.push(endOfStartTag);
3148
3149 const child = Array.isArray(children)
@@ -3142,13 +3350,14 @@ function pushImg(
3350 }
3351 }
3352 }
3145 - return pushSelfClosing(target, props, 'img');
3353 + return pushSelfClosing(target, props, 'img', formatContext);
3354 }
3355
3356 function pushSelfClosing(
3357 target: Array<Chunk | PrecomputedChunk>,
3358 props: Object,
3359 tag: string,
3360 + formatContext: FormatContext,
3361 ): null {
3362 target.push(startChunkForTag(tag));
3363
@@ -3172,6 +3381,8 @@ function pushSelfClosing(
3381 }
3382 }
3383
3384 + pushViewTransitionAttributes(target, formatContext);
3385 +
3386 target.push(endOfStartTagSelfClosing);
3387 return null;
3388 }
@@ -3179,6 +3390,7 @@ function pushSelfClosing(
3390 function pushStartMenuItem(
3391 target: Array<Chunk | PrecomputedChunk>,
3392 props: Object,
3393 + formatContext: FormatContext,
3394 ): ReactNodeList {
3395 target.push(startChunkForTag('menuitem'));
3396
@@ -3201,6 +3413,8 @@ function pushStartMenuItem(
3413 }
3414 }
3415
3416 + pushViewTransitionAttributes(target, formatContext);
3417 +
3418 target.push(endOfStartTag);
3419 return null;
3420 }
@@ -3307,6 +3521,7 @@ function pushTitleImpl(
3521 }
3522 }
3523 }
3524 + // Title never participate as a ViewTransition
3525 target.push(endOfStartTag);
3526
3527 const child = Array.isArray(children)
@@ -3355,11 +3570,16 @@ function pushStartHead(
3570 }
3571
3572 preamble.headChunks = [];
3358 - return pushStartSingletonElement(preamble.headChunks, props, 'head');
3573 + return pushStartSingletonElement(
3574 + preamble.headChunks,
3575 + props,
3576 + 'head',
3577 + formatContext,
3578 + );
3579 } else {
3580 // This <head> is deep and is likely just an error. we emit it inline though.
3581 // Validation should warn that this tag is the the wrong spot.
3362 - return pushStartGenericElement(target, props, 'head');
3582 + return pushStartGenericElement(target, props, 'head', formatContext);
3583 }
3584 }
3585
@@ -3384,11 +3604,16 @@ function pushStartBody(
3604 }
3605
3606 preamble.bodyChunks = [];
3387 - return pushStartSingletonElement(preamble.bodyChunks, props, 'body');
3607 + return pushStartSingletonElement(
3608 + preamble.bodyChunks,
3609 + props,
3610 + 'body',
3611 + formatContext,
3612 + );
3613 } else {
3614 // This <head> is deep and is likely just an error. we emit it inline though.
3615 // Validation should warn that this tag is the the wrong spot.
3391 - return pushStartGenericElement(target, props, 'body');
3616 + return pushStartGenericElement(target, props, 'body', formatContext);
3617 }
3618 }
3619
@@ -3413,11 +3638,16 @@ function pushStartHtml(
3638 }
3639
3640 preamble.htmlChunks = [DOCTYPE];
3416 - return pushStartSingletonElement(preamble.htmlChunks, props, 'html');
3641 + return pushStartSingletonElement(
3642 + preamble.htmlChunks,
3643 + props,
3644 + 'html',
3645 + formatContext,
3646 + );
3647 } else {
3648 // This <html> is deep and is likely just an error. we emit it inline though.
3649 // Validation should warn that this tag is the the wrong spot.
3420 - return pushStartGenericElement(target, props, 'html');
3650 + return pushStartGenericElement(target, props, 'html', formatContext);
3651 }
3652 }
3653
@@ -3528,6 +3758,7 @@ function pushScriptImpl(
3758 }
3759 }
3760 }
3761 + // Scripts never participate as a ViewTransition
3762 target.push(endOfStartTag);
3763
3764 if (__DEV__) {
@@ -3561,6 +3792,7 @@ function pushStartSingletonElement(
3792 target: Array<Chunk | PrecomputedChunk>,
3793 props: Object,
3794 tag: string,
3795 + formatContext: FormatContext,
3796 ): ReactNodeList {
3797 target.push(startChunkForTag(tag));
3798
@@ -3586,6 +3818,8 @@ function pushStartSingletonElement(
3818 }
3819 }
3820
3821 + pushViewTransitionAttributes(target, formatContext);
3822 +
3823 target.push(endOfStartTag);
3824 pushInnerHTML(target, innerHTML, children);
3825 return children;
@@ -3595,6 +3829,7 @@ function pushStartGenericElement(
3829 target: Array<Chunk | PrecomputedChunk>,
3830 props: Object,
3831 tag: string,
3832 + formatContext: FormatContext,
3833 ): ReactNodeList {
3834 target.push(startChunkForTag(tag));
3835
@@ -3620,6 +3855,8 @@ function pushStartGenericElement(
3855 }
3856 }
3857
3858 + pushViewTransitionAttributes(target, formatContext);
3859 +
3860 target.push(endOfStartTag);
3861 pushInnerHTML(target, innerHTML, children);
3862 if (typeof children === 'string') {
@@ -3635,6 +3872,7 @@ function pushStartCustomElement(
3872 target: Array<Chunk | PrecomputedChunk>,
3873 props: Object,
3874 tag: string,
3875 + formatContext: FormatContext,
3876 ): ReactNodeList {
3877 target.push(startChunkForTag(tag));
3878
@@ -3693,6 +3931,9 @@ function pushStartCustomElement(
3931 }
3932 }
3933
3934 + // TODO: ViewTransition attributes gets observed by the Custom Element which is a bit sketchy.
3935 + pushViewTransitionAttributes(target, formatContext);
3936 +
3937 target.push(endOfStartTag);
3938 pushInnerHTML(target, innerHTML, children);
3939 return children;
@@ -3704,6 +3945,7 @@ function pushStartPreformattedElement(
3945 target: Array<Chunk | PrecomputedChunk>,
3946 props: Object,
3947 tag: string,
3948 + formatContext: FormatContext,
3949 ): ReactNodeList {
3950 target.push(startChunkForTag(tag));
3951
@@ -3729,6 +3971,8 @@ function pushStartPreformattedElement(
3971 }
3972 }
3973
3974 + pushViewTransitionAttributes(target, formatContext);
3975 +
3976 target.push(endOfStartTag);
3977
3978 // text/html ignores the first character in these tags if it's a newline
@@ -3851,7 +4095,7 @@ export function pushStartInstance(
4095 // Fast track very common tags
4096 break;
4097 case 'a':
3854 - return pushStartAnchor(target, props);
4098 + return pushStartAnchor(target, props, formatContext);
4099 case 'g':
4100 case 'p':
4101 case 'li':
@@ -3859,21 +4103,39 @@ export function pushStartInstance(
4103 break;
4104 // Special tags
4105 case 'select':
3862 - return pushStartSelect(target, props);
4106 + return pushStartSelect(target, props, formatContext);
4107 case 'option':
4108 return pushStartOption(target, props, formatContext);
4109 case 'textarea':
3866 - return pushStartTextArea(target, props);
4110 + return pushStartTextArea(target, props, formatContext);
4111 case 'input':
3868 - return pushInput(target, props, resumableState, renderState);
4112 + return pushInput(
4113 + target,
4114 + props,
4115 + resumableState,
4116 + renderState,
4117 + formatContext,
4118 + );
4119 case 'button':
3870 - return pushStartButton(target, props, resumableState, renderState);
4120 + return pushStartButton(
4121 + target,
4122 + props,
4123 + resumableState,
4124 + renderState,
4125 + formatContext,
4126 + );
4127 case 'form':
3872 - return pushStartForm(target, props, resumableState, renderState);
4128 + return pushStartForm(
4129 + target,
4130 + props,
4131 + resumableState,
4132 + renderState,
4133 + formatContext,
4134 + );
4135 case 'menuitem':
3874 - return pushStartMenuItem(target, props);
4136 + return pushStartMenuItem(target, props, formatContext);
4137 case 'object':
3876 - return pushStartObject(target, props);
4138 + return pushStartObject(target, props, formatContext);
4139 case 'title':
4140 return pushTitle(target, props, renderState, formatContext);
4141 case 'link':
@@ -3910,7 +4172,7 @@ export function pushStartInstance(
4172 // Newline eating tags
4173 case 'listing':
4174 case 'pre': {
3913 - return pushStartPreformattedElement(target, props, type);
4175 + return pushStartPreformattedElement(target, props, type, formatContext);
4176 }
4177 case 'img': {
4178 return pushImg(target, props, resumableState, renderState, formatContext);
@@ -3927,7 +4189,7 @@ export function pushStartInstance(
4189 case 'source':
4190 case 'track':
4191 case 'wbr': {
3930 - return pushSelfClosing(target, props, type);
4192 + return pushSelfClosing(target, props, type, formatContext);
4193 }
4194 // These are reserved SVG and MathML elements, that are never custom elements.
4195 // https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-core-concepts
@@ -3970,12 +4232,12 @@ export function pushStartInstance(
4232 default: {
4233 if (type.indexOf('-') !== -1) {
4234 // Custom element
3973 - return pushStartCustomElement(target, props, type);
4235 + return pushStartCustomElement(target, props, type, formatContext);
4236 }
4237 }
4238 }
4239 // Generic element
3978 - return pushStartGenericElement(target, props, type);
4240 + return pushStartGenericElement(target, props, type, formatContext);
4241 }
4242
4243 const endTagCache = new Map<string, PrecomputedChunk>();
packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js
+14
@@ -14,6 +14,7 @@ import type {
14 Resource,
15 HeadersDescriptor,
16 PreambleState,
17 + FormatContext,
18 } from './ReactFizzConfigDOM';
19
20 import {
@@ -179,6 +180,19 @@ export {
180
181 import escapeTextForBrowser from './escapeTextForBrowser';
182
183 +export function getViewTransitionFormatContext(
184 + parentContext: FormatContext,
185 + update: void | null | 'none' | 'auto' | string,
186 + enter: void | null | 'none' | 'auto' | string,
187 + exit: void | null | 'none' | 'auto' | string,
188 + share: void | null | 'none' | 'auto' | string,
189 + name: void | null | 'auto' | string,
190 + autoName: string, // name or an autogenerated unique name
191 +): FormatContext {
192 + // ViewTransition reveals are not supported in legacy renders.
193 + return parentContext;
194 +}
195 +
196 export function pushTextInstance(
197 target: Array<Chunk | PrecomputedChunk>,
198 text: string,
packages/react-dom/src/__tests__/ReactDOMFizzViewTransition-test.js new
+335
@@ -0,0 +1,335 @@
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 + * @emails react-core
8 + * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment
9 + */
10 +
11 +'use strict';
12 +import {
13 + insertNodesAndExecuteScripts,
14 + getVisibleChildren,
15 +} from '../test-utils/FizzTestUtils';
16 +
17 +let JSDOM;
18 +let React;
19 +let Suspense;
20 +let ViewTransition;
21 +let ReactDOMClient;
22 +let clientAct;
23 +let ReactDOMFizzServer;
24 +let Stream;
25 +let document;
26 +let writable;
27 +let container;
28 +let buffer = '';
29 +let hasErrored = false;
30 +let fatalError = undefined;
31 +
32 +describe('ReactDOMFizzViewTransition', () => {
33 + beforeEach(() => {
34 + jest.resetModules();
35 + JSDOM = require('jsdom').JSDOM;
36 + React = require('react');
37 + ReactDOMClient = require('react-dom/client');
38 + clientAct = require('internal-test-utils').act;
39 + ReactDOMFizzServer = require('react-dom/server');
40 + Stream = require('stream');
41 +
42 + Suspense = React.Suspense;
43 + ViewTransition = React.unstable_ViewTransition;
44 +
45 + // Test Environment
46 + const jsdom = new JSDOM(
47 + '<!DOCTYPE html><html><head></head><body><div id="container">',
48 + {
49 + runScripts: 'dangerously',
50 + },
51 + );
52 + document = jsdom.window.document;
53 + container = document.getElementById('container');
54 + global.window = jsdom.window;
55 + // The Fizz runtime assumes requestAnimationFrame exists so we need to polyfill it.
56 + global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
57 + setTimeout(cb);
58 +
59 + buffer = '';
60 + hasErrored = false;
61 +
62 + writable = new Stream.PassThrough();
63 + writable.setEncoding('utf8');
64 + writable.on('data', chunk => {
65 + buffer += chunk;
66 + });
67 + writable.on('error', error => {
68 + hasErrored = true;
69 + fatalError = error;
70 + });
71 + });
72 +
73 + afterEach(() => {
74 + jest.restoreAllMocks();
75 + });
76 +
77 + async function serverAct(callback) {
78 + await callback();
79 + // Await one turn around the event loop.
80 + // This assumes that we'll flush everything we have so far.
81 + await new Promise(resolve => {
82 + setImmediate(resolve);
83 + });
84 + if (hasErrored) {
85 + throw fatalError;
86 + }
87 + // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
88 + // We also want to execute any scripts that are embedded.
89 + // We assume that we have now received a proper fragment of HTML.
90 + const bufferedContent = buffer;
91 + buffer = '';
92 + const temp = document.createElement('body');
93 + temp.innerHTML = bufferedContent;
94 + await insertNodesAndExecuteScripts(temp, container, null);
95 + jest.runAllTimers();
96 + }
97 +
98 + // @gate enableViewTransition
99 + it('emits annotations for view transitions', async () => {
100 + function App() {
101 + return (
102 + <div>
103 + <ViewTransition>
104 + <div />
105 + </ViewTransition>
106 + <ViewTransition name="foo" update="bar">
107 + <div />
108 + </ViewTransition>
109 + <ViewTransition update={{something: 'a', default: 'baz'}}>
110 + <div />
111 + </ViewTransition>
112 + <ViewTransition name="outer" update="bar" share="pair">
113 + <ViewTransition>
114 + <div />
115 + </ViewTransition>
116 + </ViewTransition>
117 + </div>
118 + );
119 + }
120 +
121 + await serverAct(async () => {
122 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
123 + pipe(writable);
124 + });
125 +
126 + expect(getVisibleChildren(container)).toEqual(
127 + <div>
128 + <div vt-update="auto" />
129 + <div vt-name="foo" vt-update="bar" vt-share="auto" />
130 + <div vt-update="baz" />
131 + <div vt-name="outer" vt-update="auto" vt-share="pair" />
132 + </div>,
133 + );
134 +
135 + // Hydration should not yield any errors.
136 + await clientAct(async () => {
137 + ReactDOMClient.hydrateRoot(container, <App />);
138 + });
139 + });
140 +
141 + // @gate enableViewTransition
142 + it('emits enter/exit annotations for view transitions inside Suspense', async () => {
143 + let resolve;
144 + const promise = new Promise(r => (resolve = r));
145 + function Suspend() {
146 + return React.use(promise);
147 + }
148 + function App() {
149 + const fallback = (
150 + <ViewTransition>
151 + <div>
152 + <ViewTransition>
153 + <span>Loading</span>
154 + </ViewTransition>
155 + </div>
156 + </ViewTransition>
157 + );
158 + return (
159 + <div>
160 + <Suspense fallback={fallback}>
161 + <ViewTransition>
162 + <Suspend />
163 + </ViewTransition>
164 + </Suspense>
165 + </div>
166 + );
167 + }
168 +
169 + await serverAct(async () => {
170 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
171 + pipe(writable);
172 + });
173 +
174 + expect(getVisibleChildren(container)).toEqual(
175 + <div>
176 + <div vt-update="auto" vt-exit="auto">
177 + <span vt-update="auto">Loading</span>
178 + </div>
179 + </div>,
180 + );
181 +
182 + await serverAct(async () => {
183 + await resolve(
184 + <div>
185 + <ViewTransition>
186 + <span>Content</span>
187 + </ViewTransition>
188 + </div>,
189 + );
190 + });
191 +
192 + expect(getVisibleChildren(container)).toEqual(
193 + <div>
194 + <div vt-update="auto" vt-enter="auto">
195 + <span vt-update="auto">Content</span>
196 + </div>
197 + </div>,
198 + );
199 +
200 + // Hydration should not yield any errors.
201 + await clientAct(async () => {
202 + ReactDOMClient.hydrateRoot(container, <App />);
203 + });
204 + });
205 +
206 + // @gate enableViewTransition
207 + it('can emit both enter and exit on the same node', async () => {
208 + let resolve;
209 + const promise = new Promise(r => (resolve = r));
210 + function Suspend() {
211 + return React.use(promise);
212 + }
213 + function App() {
214 + const fallback = (
215 + <Suspense fallback={null}>
216 + <ViewTransition enter="hello" exit="goodbye">
217 + <div>
218 + <ViewTransition>
219 + <span>Loading</span>
220 + </ViewTransition>
221 + </div>
222 + </ViewTransition>
223 + </Suspense>
224 + );
225 + return (
226 + <div>
227 + <Suspense fallback={fallback}>
228 + <ViewTransition enter="hi">
229 + <Suspend />
230 + </ViewTransition>
231 + </Suspense>
232 + </div>
233 + );
234 + }
235 +
236 + await serverAct(async () => {
237 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
238 + pipe(writable);
239 + });
240 +
241 + expect(getVisibleChildren(container)).toEqual(
242 + <div>
243 + <div vt-update="auto" vt-enter="hello" vt-exit="goodbye">
244 + <span vt-update="auto">Loading</span>
245 + </div>
246 + </div>,
247 + );
248 +
249 + await serverAct(async () => {
250 + await resolve(
251 + <div>
252 + <ViewTransition>
253 + <span>Content</span>
254 + </ViewTransition>
255 + </div>,
256 + );
257 + });
258 +
259 + expect(getVisibleChildren(container)).toEqual(
260 + <div>
261 + <div vt-update="auto" vt-enter="hi">
262 + <span vt-update="auto">Content</span>
263 + </div>
264 + </div>,
265 + );
266 +
267 + // Hydration should not yield any errors.
268 + await clientAct(async () => {
269 + ReactDOMClient.hydrateRoot(container, <App />);
270 + });
271 + });
272 +
273 + // @gate enableViewTransition
274 + it('emits annotations for view transitions outside Suspense', async () => {
275 + let resolve;
276 + const promise = new Promise(r => (resolve = r));
277 + function Suspend() {
278 + return React.use(promise);
279 + }
280 + function App() {
281 + const fallback = (
282 + <div>
283 + <ViewTransition>
284 + <span>Loading</span>
285 + </ViewTransition>
286 + </div>
287 + );
288 + return (
289 + <div>
290 + <ViewTransition>
291 + <Suspense fallback={fallback}>
292 + <Suspend />
293 + </Suspense>
294 + </ViewTransition>
295 + </div>
296 + );
297 + }
298 +
299 + await serverAct(async () => {
300 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
301 + pipe(writable);
302 + });
303 +
304 + expect(getVisibleChildren(container)).toEqual(
305 + <div>
306 + <div vt-name="«R0»" vt-update="auto" vt-share="auto">
307 + <span vt-update="auto">Loading</span>
308 + </div>
309 + </div>,
310 + );
311 +
312 + await serverAct(async () => {
313 + await resolve(
314 + <div>
315 + <ViewTransition>
316 + <span>Content</span>
317 + </ViewTransition>
318 + </div>,
319 + );
320 + });
321 +
322 + expect(getVisibleChildren(container)).toEqual(
323 + <div>
324 + <div vt-name="«R0»" vt-update="auto" vt-share="auto">
325 + <span vt-update="auto">Content</span>
326 + </div>
327 + </div>,
328 + );
329 +
330 + // Hydration should not yield any errors.
331 + await clientAct(async () => {
332 + ReactDOMClient.hydrateRoot(container, <App />);
333 + });
334 + });
335 +});
packages/react-markup/src/ReactFizzConfigMarkup.js
+13
@@ -88,6 +88,19 @@ export {
88
89 import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser';
90
91 +export function getViewTransitionFormatContext(
92 + parentContext: FormatContext,
93 + update: void | null | 'none' | 'auto' | string,
94 + enter: void | null | 'none' | 'auto' | string,
95 + exit: void | null | 'none' | 'auto' | string,
96 + share: void | null | 'none' | 'auto' | string,
97 + name: void | null | 'auto' | string,
98 + autoName: string, // name or an autogenerated unique name
99 +): FormatContext {
100 + // ViewTransition reveals are not supported in markup renders.
101 + return parentContext;
102 +}
103 +
104 export function pushStartInstance(
105 target: Array<Chunk | PrecomputedChunk>,
106 type: string,
packages/react-noop-renderer/src/ReactNoopServer.js
+4
@@ -111,6 +111,10 @@ const ReactNoopServer = ReactFizzServer({
111 return null;
112 },
113
114 + getViewTransitionFormatContext(): null {
115 + return null;
116 + },
117 +
118 resetResumableState(): void {},
119 completeResumableState(): void {},
120
packages/react-server/src/ReactFizzServer.js
+22
@@ -76,6 +76,7 @@ import {
76 getChildFormatContext,
77 getSuspenseFallbackFormatContext,
78 getSuspenseContentFormatContext,
79 + getViewTransitionFormatContext,
80 writeHoistables,
81 writePreambleStart,
82 writePreambleEnd,
@@ -140,6 +141,10 @@ import {
141 callComponentInDEV,
142 callRenderInDEV,
143 } from './ReactFizzCallUserSpace';
144 +import {
145 + getViewTransitionClassName,
146 + getViewTransitionName,
147 +} from './ReactFizzViewTransitionComponent';
148
149 import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
150 import {
@@ -2270,7 +2275,23 @@ function renderViewTransition(
2275 keyPath: KeyNode,
2276 props: ViewTransitionProps,
2277 ) {
2278 + const prevContext = task.formatContext;
2279 const prevKeyPath = task.keyPath;
2280 + // Get the name off props or generate an auto-generated one in case we need it.
2281 + const autoName = getViewTransitionName(
2282 + props,
2283 + task.treeContext,
2284 + request.resumableState,
2285 + );
2286 + task.formatContext = getViewTransitionFormatContext(
2287 + prevContext,
2288 + getViewTransitionClassName(props.default, props.update),
2289 + getViewTransitionClassName(props.default, props.enter),
2290 + getViewTransitionClassName(props.default, props.exit),
2291 + getViewTransitionClassName(props.default, props.share),
2292 + props.name,
2293 + autoName,
2294 + );
2295 task.keyPath = keyPath;
2296 if (props.name != null && props.name !== 'auto') {
2297 renderNodeDestructive(request, task, props.children, -1);
@@ -2289,6 +2310,7 @@ function renderViewTransition(
2310 // because renderNode takes care of unwinding the stack.
2311 task.treeContext = prevTreeContext;
2312 }
2313 + task.formatContext = prevContext;
2314 task.keyPath = prevKeyPath;
2315 }
2316
packages/react-server/src/ReactFizzViewTransitionComponent.js new
+72
@@ -0,0 +1,72 @@
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 {ViewTransitionProps, ViewTransitionClass} from 'shared/ReactTypes';
11 +import type {TreeContext} from './ReactFizzTreeContext';
12 +import type {ResumableState} from './ReactFizzConfig';
13 +
14 +import {getTreeId} from './ReactFizzTreeContext';
15 +import {makeId} from './ReactFizzConfig';
16 +
17 +export function getViewTransitionName(
18 + props: ViewTransitionProps,
19 + treeContext: TreeContext,
20 + resumableState: ResumableState,
21 +): string {
22 + if (props.name != null && props.name !== 'auto') {
23 + return props.name;
24 + }
25 + const treeId = getTreeId(treeContext);
26 + return makeId(resumableState, treeId, 0);
27 +}
28 +
29 +function getClassNameByType(classByType: ?ViewTransitionClass): ?string {
30 + if (classByType == null || typeof classByType === 'string') {
31 + return classByType;
32 + }
33 + let className: ?string = null;
34 + const activeTypes = null; // TODO: Support passing active types.
35 + if (activeTypes !== null) {
36 + for (let i = 0; i < activeTypes.length; i++) {
37 + const match = classByType[activeTypes[i]];
38 + if (match != null) {
39 + if (match === 'none') {
40 + // If anything matches "none" that takes precedence over any other
41 + // type that also matches.
42 + return 'none';
43 + }
44 + if (className == null) {
45 + className = match;
46 + } else {
47 + className += ' ' + match;
48 + }
49 + }
50 + }
51 + }
52 + if (className == null) {
53 + // We had no other matches. Match the default for this configuration.
54 + return classByType.default;
55 + }
56 + return className;
57 +}
58 +
59 +export function getViewTransitionClassName(
60 + defaultClass: ?ViewTransitionClass,
61 + eventClass: ?ViewTransitionClass,
62 +): ?string {
63 + const className: ?string = getClassNameByType(defaultClass);
64 + const eventClassName: ?string = getClassNameByType(eventClass);
65 + if (eventClassName == null) {
66 + return className === 'auto' ? null : className;
67 + }
68 + if (eventClassName === 'auto') {
69 + return null;
70 + }
71 + return eventClassName;
72 +}
packages/react-server/src/forks/ReactFizzConfig.custom.js
+2
@@ -52,6 +52,8 @@ export const getSuspenseFallbackFormatContext =
52 $$$config.getSuspenseFallbackFormatContext;
53 export const getSuspenseContentFormatContext =
54 $$$config.getSuspenseContentFormatContext;
55 +export const getViewTransitionFormatContext =
56 + $$$config.getViewTransitionFormatContext;
57 export const makeId = $$$config.makeId;
58 export const pushTextInstance = $$$config.pushTextInstance;
59 export const pushStartInstance = $$$config.pushStartInstance;