diff --git a/.eslintrc.js b/.eslintrc.js index 49846c1f5e9bc..c1eb5b34ebe82 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -579,6 +579,7 @@ module.exports = { JSONValue: 'readonly', JSResourceReference: 'readonly', MouseEventHandler: 'readonly', + NavigateEvent: 'readonly', PropagationPhases: 'readonly', PropertyDescriptor: 'readonly', React$AbstractComponent: 'readonly', @@ -634,5 +635,6 @@ module.exports = { AsyncLocalStorage: 'readonly', async_hooks: 'readonly', globalThis: 'readonly', + navigation: 'readonly', }, }; diff --git a/.github/workflows/compiler_discord_notify.yml b/.github/workflows/compiler_discord_notify.yml index 71aea56e8492f..7a5f5db0fb988 100644 --- a/.github/workflows/compiler_discord_notify.yml +++ b/.github/workflows/compiler_discord_notify.yml @@ -15,6 +15,7 @@ jobs: outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} diff --git a/.github/workflows/runtime_commit_artifacts.yml b/.github/workflows/runtime_commit_artifacts.yml index ab0e71b83cfc7..b982d561ed71c 100644 --- a/.github/workflows/runtime_commit_artifacts.yml +++ b/.github/workflows/runtime_commit_artifacts.yml @@ -332,10 +332,10 @@ jobs: git --no-pager diff -U0 --cached | grep '^[+-]' | head -n 100 echo "====================" # Ignore REVISION or lines removing @generated headers. - if git diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then + if git diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" > /dev/null; then echo "Changes detected" echo "===== Changes =====" - git --no-pager diff --cached ':(exclude)*REVISION' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50 + git --no-pager diff --cached ':(exclude)*REVISION' ':(exclude)*/eslint-plugin-react-hooks/package.json' | grep -vE "^(@@|diff|index|\-\-\-|\+\+\+|\- \* @generated SignedSource)" | grep "^[+-]" | head -n 50 echo "===================" echo "should_commit=true" >> "$GITHUB_OUTPUT" else diff --git a/.github/workflows/runtime_discord_notify.yml b/.github/workflows/runtime_discord_notify.yml index 44775fbe78880..69e4c3453f343 100644 --- a/.github/workflows/runtime_discord_notify.yml +++ b/.github/workflows/runtime_discord_notify.yml @@ -15,6 +15,7 @@ jobs: outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} diff --git a/.github/workflows/shared_label_core_team_prs.yml b/.github/workflows/shared_label_core_team_prs.yml index fd4aa9399e386..cc10e87dcc2cf 100644 --- a/.github/workflows/shared_label_core_team_prs.yml +++ b/.github/workflows/shared_label_core_team_prs.yml @@ -17,6 +17,7 @@ jobs: outputs: is_member_or_collaborator: ${{ steps.check_is_member_or_collaborator.outputs.is_member_or_collaborator }} steps: + - run: echo ${{ github.event.pull_request.author_association }} - name: Check is member or collaborator id: check_is_member_or_collaborator if: ${{ github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'COLLABORATOR' }} diff --git a/compiler/CHANGELOG.md b/compiler/CHANGELOG.md index 022d066b2202f..32e21efba0cd5 100644 --- a/compiler/CHANGELOG.md +++ b/compiler/CHANGELOG.md @@ -1,3 +1,9 @@ +## 19.1.0-rc.2 (May 14, 2025) + +## babel-plugin-react-compiler + +* Fix for string attribute values with emoji [#33096](https://github.com/facebook/react/pull/33096) by [@josephsavona](https://github.com/josephsavona) + ## 19.1.0-rc.1 (April 21, 2025) ## eslint-plugin-react-hooks diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) deleted file mode 100644 index f4354f427cf87..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/object-method-shorthand-3.expect.md~051f3e57 ([hir] Do not memoize object methods separately) +++ /dev/null @@ -1,47 +0,0 @@ - -## Input - -```javascript -import { mutate } from "shared-runtime"; - -function Component(a) { - const x = { a }; - let obj = { - method() { - mutate(x); - return x; - }, - }; - return obj.method(); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ x: 1 }, { a: 2 }, { b: 2 }], -}; - -``` - -## Code - -```javascript -import { mutate } from "shared-runtime"; - -function Component(a) { - const x = { a }; - const obj = { - method() { - mutate(x); - return x; - }, - }; - return obj.method(); -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ x: 1 }, { a: 2 }, { b: 2 }], -}; - -``` - \ No newline at end of file diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js index aa19871a9dcbb..0b9b9c315d647 100644 --- a/fixtures/flight/src/actions.js +++ b/fixtures/flight/src/actions.js @@ -2,7 +2,13 @@ import {setServerState} from './ServerState.js'; +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + export async function like() { + // Test loading state + await sleep(1000); setServerState('Liked!'); return new Promise((resolve, reject) => resolve('Liked')); } @@ -20,5 +26,7 @@ export async function greet(formData) { } export async function increment(n) { + // Test loading state + await sleep(1000); return n + 1; } diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 9744313c4f5ea..db2cd0aff08c7 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -18,6 +18,10 @@ import './Page.css'; import transitions from './Transitions.module.css'; +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + const a = (
@@ -106,7 +110,13 @@ export default function Page({url, navigate}) { document.body ) ) : ( - ); diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index 055474ea321e0..b699a5a8f2076 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -7746,6 +7746,34 @@ const testsFlow = { }, ], invalid: [ + { + code: normalizeIndent` + hook useExample(a) { + useEffect(() => { + console.log(a); + }, []); + } + `, + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'a'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [a]', + output: normalizeIndent` + hook useExample(a) { + useEffect(() => { + console.log(a); + }, [a]); + } + `, + }, + ], + }, + ], + }, { code: normalizeIndent` function Foo() { @@ -8311,7 +8339,9 @@ describe('rules-of-hooks/exhaustive-deps', () => { }, }; - const testsBabelEslint = { + const testsBabelEslint = tests; + + const testsHermesParser = { valid: [...testsFlow.valid, ...tests.valid], invalid: [...testsFlow.invalid, ...tests.invalid], }; @@ -8336,6 +8366,33 @@ describe('rules-of-hooks/exhaustive-deps', () => { testsBabelEslint ); + new ESLintTesterV7({ + parser: require.resolve('hermes-eslint'), + parserOptions: { + sourceType: 'module', + enableExperimentalComponentSyntax: true, + }, + }).run( + 'eslint: v7, parser: hermes-eslint', + ReactHooksESLintRule, + testsHermesParser + ); + + new ESLintTesterV9({ + languageOptions: { + ...languageOptionsV9, + parser: require('hermes-eslint'), + parserOptions: { + sourceType: 'module', + enableExperimentalComponentSyntax: true, + }, + }, + }).run( + 'eslint: v9, parser: hermes-eslint', + ReactHooksESLintRule, + testsHermesParser + ); + const testsTypescriptEslintParser = { valid: [...testsTypescript.valid, ...tests.valid], invalid: [...testsTypescript.invalid, ...tests.invalid], diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index 1b0059757278f..d33e0430139a3 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -203,7 +203,13 @@ const rule = { let currentScope = scope.upper; while (currentScope) { pureScopes.add(currentScope); - if (currentScope.type === 'function') { + if ( + currentScope.type === 'function' || + // @ts-expect-error incorrect TS types + currentScope.type === 'hook' || + // @ts-expect-error incorrect TS types + currentScope.type === 'component' + ) { break; } currentScope = currentScope.upper; diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 711d76c92d075..4fc59a24d9272 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -50,6 +50,7 @@ import { gt, gte, parseSourceFromComponentStack, + parseSourceFromOwnerStack, serializeToString, } from 'react-devtools-shared/src/backend/utils'; import { @@ -5805,15 +5806,13 @@ export function attach( function getSourceForFiberInstance( fiberInstance: FiberInstance, ): Source | null { - const unresolvedSource = fiberInstance.source; - if ( - unresolvedSource !== null && - typeof unresolvedSource === 'object' && - !isError(unresolvedSource) - ) { - // $FlowFixMe: isError should have refined it. - return unresolvedSource; + // Favor the owner source if we have one. + const ownerSource = getSourceForInstance(fiberInstance); + if (ownerSource !== null) { + return ownerSource; } + + // Otherwise fallback to the throwing trick. const dispatcherRef = getDispatcherRef(renderer); const stackFrame = dispatcherRef == null @@ -5824,10 +5823,7 @@ export function attach( dispatcherRef, ); if (stackFrame === null) { - // If we don't find a source location by throwing, try to get one - // from an owned child if possible. This is the same branch as - // for virtual instances. - return getSourceForInstance(fiberInstance); + return null; } const source = parseSourceFromComponentStack(stackFrame); fiberInstance.source = source; @@ -5835,7 +5831,7 @@ export function attach( } function getSourceForInstance(instance: DevToolsInstance): Source | null { - let unresolvedSource = instance.source; + const unresolvedSource = instance.source; if (unresolvedSource === null) { // We don't have any source yet. We can try again later in case an owned child mounts later. // TODO: We won't have any information here if the child is filtered. @@ -5848,7 +5844,9 @@ export function attach( // any intermediate utility functions. This won't point to the top of the component function // but it's at least somewhere within it. if (isError(unresolvedSource)) { - unresolvedSource = formatOwnerStack((unresolvedSource: any)); + return (instance.source = parseSourceFromOwnerStack( + (unresolvedSource: any), + )); } if (typeof unresolvedSource === 'string') { const idx = unresolvedSource.lastIndexOf('\n'); diff --git a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js index e03d948a45d3a..7d4cfa65ce030 100644 --- a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js +++ b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js @@ -13,8 +13,12 @@ export function formatOwnerStack(error: Error): string { const prevPrepareStackTrace = Error.prepareStackTrace; // $FlowFixMe[incompatible-type] It does accept undefined. Error.prepareStackTrace = undefined; - let stack = error.stack; + const stack = error.stack; Error.prepareStackTrace = prevPrepareStackTrace; + return formatOwnerStackString(stack); +} + +export function formatOwnerStackString(stack: string): string { if (stack.startsWith('Error: react-stack-top-frame\n')) { // V8's default formatting prefixes with the error message which we // don't want/need. diff --git a/packages/react-devtools-shared/src/backend/utils/index.js b/packages/react-devtools-shared/src/backend/utils/index.js index 977683ef9a208..950161a020b21 100644 --- a/packages/react-devtools-shared/src/backend/utils/index.js +++ b/packages/react-devtools-shared/src/backend/utils/index.js @@ -18,6 +18,8 @@ import type {DehydratedData} from 'react-devtools-shared/src/frontend/types'; export {default as formatWithStyles} from './formatWithStyles'; export {default as formatConsoleArguments} from './formatConsoleArguments'; +import {formatOwnerStackString} from '../shared/DevToolsOwnerStack'; + // TODO: update this to the first React version that has a corresponding DevTools backend const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9'; export function hasAssignedBackend(version?: string): boolean { @@ -345,6 +347,77 @@ export function parseSourceFromComponentStack( return parseSourceFromFirefoxStack(componentStack); } +let collectedLocation: Source | null = null; + +function collectStackTrace( + error: Error, + structuredStackTrace: CallSite[], +): string { + let result: null | Source = null; + // Collect structured stack traces from the callsites. + // We mirror how V8 serializes stack frames and how we later parse them. + for (let i = 0; i < structuredStackTrace.length; i++) { + const callSite = structuredStackTrace[i]; + if (callSite.getFunctionName() === 'react-stack-bottom-frame') { + // We pick the last frame that matches before the bottom frame since + // that will be immediately inside the component as opposed to some helper. + // If we don't find a bottom frame then we bail to string parsing. + collectedLocation = result; + // Skip everything after the bottom frame since it'll be internals. + break; + } else { + const sourceURL = callSite.getScriptNameOrSourceURL(); + const line = + // $FlowFixMe[prop-missing] + typeof callSite.getEnclosingLineNumber === 'function' + ? (callSite: any).getEnclosingLineNumber() + : callSite.getLineNumber(); + const col = + // $FlowFixMe[prop-missing] + typeof callSite.getEnclosingColumnNumber === 'function' + ? (callSite: any).getEnclosingColumnNumber() + : callSite.getLineNumber(); + if (!sourceURL || !line || !col) { + // Skip eval etc. without source url. They don't have location. + continue; + } + result = { + sourceURL, + line: line, + column: col, + }; + } + } + // At the same time we generate a string stack trace just in case someone + // else reads it. + const name = error.name || 'Error'; + const message = error.message || ''; + let stack = name + ': ' + message; + for (let i = 0; i < structuredStackTrace.length; i++) { + stack += '\n at ' + structuredStackTrace[i].toString(); + } + return stack; +} + +export function parseSourceFromOwnerStack(error: Error): Source | null { + // First attempt to collected the structured data using prepareStackTrace. + collectedLocation = null; + const previousPrepare = Error.prepareStackTrace; + Error.prepareStackTrace = collectStackTrace; + let stack; + try { + stack = error.stack; + } finally { + Error.prepareStackTrace = previousPrepare; + } + if (collectedLocation !== null) { + return collectedLocation; + } + // Fallback to parsing the string form. + const componentStack = formatOwnerStackString(stack); + return parseSourceFromComponentStack(componentStack); +} + // 0.123456789 => 0.123 // Expects high-resolution timestamp in milliseconds, like from performance.now() // Mainly used for optimizing the size of serialized profiling payload diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 16e3bceb4a6ec..549b279f1da50 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -72,6 +72,7 @@ import { enableScrollEndPolyfill, enableSrcObject, enableTrustedTypesIntegration, + enableViewTransition, } from 'shared/ReactFeatureFlags'; import { mediaEventTypes, @@ -3217,6 +3218,18 @@ export function diffHydratedProperties( break; case 'selected': break; + case 'vt-name': + case 'vt-update': + case 'vt-enter': + case 'vt-exit': + case 'vt-share': + if (enableViewTransition) { + // View Transition annotations are expected from the Server Runtime. + // However, if they're also specified on the client and don't match + // that's an error. + break; + } + // Fallthrough default: // Intentionally use the original name. // See discussion in https://github.com/facebook/react/pull/10676. diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index a54a623bb3954..8dbc6831e1fca 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -34,6 +34,8 @@ import {Children} from 'react'; import { enableFizzExternalRuntime, enableSrcObject, + enableFizzBlockingRender, + enableViewTransition, } from 'shared/ReactFeatureFlags'; import type { @@ -740,26 +742,47 @@ const HTML_COLGROUP_MODE = 9; type InsertionMode = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; -const NO_SCOPE = /* */ 0b00; -const NOSCRIPT_SCOPE = /* */ 0b01; -const PICTURE_SCOPE = /* */ 0b10; +const NO_SCOPE = /* */ 0b00000; +const NOSCRIPT_SCOPE = /* */ 0b00001; +const PICTURE_SCOPE = /* */ 0b00010; +const FALLBACK_SCOPE = /* */ 0b00100; +const EXIT_SCOPE = /* */ 0b01000; // A direct Instance below a Suspense fallback is the only thing that can "exit" +const ENTER_SCOPE = /* */ 0b10000; // A direct Instance below Suspense content is the only thing that can "enter" + +// Everything not listed here are tracked for the whole subtree as opposed to just +// until the next Instance. +const SUBTREE_SCOPE = ~(ENTER_SCOPE | EXIT_SCOPE); + +type ViewTransitionContext = { + update: 'none' | 'auto' | string, + // null here means that this case can never trigger. Not "auto" like it does in props. + enter: null | 'none' | 'auto' | string, + exit: null | 'none' | 'auto' | string, + share: null | 'none' | 'auto' | string, + name: 'auto' | string, + autoName: string, // a name that can be used if an explicit one is not defined. + nameIdx: number, // keeps track of how many duplicates of this name we've emitted. +}; // Lets us keep track of contextual state and pick it back up after suspending. export type FormatContext = { insertionMode: InsertionMode, // root/svg/html/mathml/table selectedValue: null | string | Array, // the selected value(s) inside a tagScope: number, + viewTransition: null | ViewTransitionContext, // tracks if we're inside a ViewTransition outside the first DOM node }; function createFormatContext( insertionMode: InsertionMode, - selectedValue: null | string, + selectedValue: null | string | Array, tagScope: number, + viewTransition: null | ViewTransitionContext, ): FormatContext { return { insertionMode, selectedValue, tagScope, + viewTransition, }; } @@ -774,7 +797,7 @@ export function createRootFormatContext(namespaceURI?: string): FormatContext { : namespaceURI === 'http://www.w3.org/1998/Math/MathML' ? MATHML_MODE : ROOT_HTML_MODE; - return createFormatContext(insertionMode, null, NO_SCOPE); + return createFormatContext(insertionMode, null, NO_SCOPE, null); } export function getChildFormatContext( @@ -782,87 +805,211 @@ export function getChildFormatContext( type: string, props: Object, ): FormatContext { + const subtreeScope = parentContext.tagScope & SUBTREE_SCOPE; switch (type) { case 'noscript': return createFormatContext( HTML_MODE, null, - parentContext.tagScope | NOSCRIPT_SCOPE, + subtreeScope | NOSCRIPT_SCOPE, + null, ); case 'select': return createFormatContext( HTML_MODE, props.value != null ? props.value : props.defaultValue, - parentContext.tagScope, + subtreeScope, + null, ); case 'svg': - return createFormatContext(SVG_MODE, null, parentContext.tagScope); + return createFormatContext(SVG_MODE, null, subtreeScope, null); case 'picture': return createFormatContext( HTML_MODE, null, - parentContext.tagScope | PICTURE_SCOPE, + subtreeScope | PICTURE_SCOPE, + null, ); case 'math': - return createFormatContext(MATHML_MODE, null, parentContext.tagScope); + return createFormatContext(MATHML_MODE, null, subtreeScope, null); case 'foreignObject': - return createFormatContext(HTML_MODE, null, parentContext.tagScope); + return createFormatContext(HTML_MODE, null, subtreeScope, null); // Table parents are special in that their children can only be created at all if they're // wrapped in a table parent. So we need to encode that we're entering this mode. case 'table': - return createFormatContext(HTML_TABLE_MODE, null, parentContext.tagScope); + return createFormatContext(HTML_TABLE_MODE, null, subtreeScope, null); case 'thead': case 'tbody': case 'tfoot': return createFormatContext( HTML_TABLE_BODY_MODE, null, - parentContext.tagScope, - ); - case 'colgroup': - return createFormatContext( - HTML_COLGROUP_MODE, + subtreeScope, null, - parentContext.tagScope, ); + case 'colgroup': + return createFormatContext(HTML_COLGROUP_MODE, null, subtreeScope, null); case 'tr': - return createFormatContext( - HTML_TABLE_ROW_MODE, - null, - parentContext.tagScope, - ); + return createFormatContext(HTML_TABLE_ROW_MODE, null, subtreeScope, null); case 'head': if (parentContext.insertionMode < HTML_MODE) { // We are either at the root or inside the tag and can enter // the scope - return createFormatContext( - HTML_HEAD_MODE, - null, - parentContext.tagScope, - ); + return createFormatContext(HTML_HEAD_MODE, null, subtreeScope, null); } break; case 'html': if (parentContext.insertionMode === ROOT_HTML_MODE) { - return createFormatContext( - HTML_HTML_MODE, - null, - parentContext.tagScope, - ); + return createFormatContext(HTML_HTML_MODE, null, subtreeScope, null); } break; } if (parentContext.insertionMode >= HTML_TABLE_MODE) { // Whatever tag this was, it wasn't a table parent or other special parent, so we must have // entered plain HTML again. - return createFormatContext(HTML_MODE, null, parentContext.tagScope); + return createFormatContext(HTML_MODE, null, subtreeScope, null); } if (parentContext.insertionMode < HTML_MODE) { - return createFormatContext(HTML_MODE, null, parentContext.tagScope); + return createFormatContext(HTML_MODE, null, subtreeScope, null); + } + if (enableViewTransition) { + if (parentContext.viewTransition !== null) { + // If we're inside a view transition, regardless what element we were in, it consumes + // the view transition context. + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + subtreeScope, + null, + ); + } + } + if (parentContext.tagScope !== subtreeScope) { + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + subtreeScope, + null, + ); } return parentContext; } +function getSuspenseViewTransition( + parentViewTransition: null | ViewTransitionContext, +): null | ViewTransitionContext { + if (parentViewTransition === null) { + return null; + } + // If a ViewTransition wraps a Suspense boundary it applies to the children Instances + // in both the fallback and the content. + // Since we only have a representation of ViewTransitions on the Instances themselves + // we cannot model the parent ViewTransition activating "enter", "exit" or "share" + // since those would be ambiguous with the Suspense boundary changing states and + // affecting the same Instances. + // We also can't model an "update" when that update is fallback nodes swapping for + // content nodes. However, we can model is as a "share" from the fallback nodes to + // the content nodes using the same name. We just have to assign the same name that + // we would've used (the parent ViewTransition name or auto-assign one). + const viewTransition: ViewTransitionContext = { + update: parentViewTransition.update, // For deep updates. + enter: null, + exit: null, + share: parentViewTransition.update, // For exit or enter of reveals. + name: parentViewTransition.autoName, + autoName: parentViewTransition.autoName, + // TOOD: If we have more than just this Suspense boundary as a child of the ViewTransition + // then the parent needs to isolate the names so that they don't conflict. + nameIdx: 0, + }; + return viewTransition; +} + +export function getSuspenseFallbackFormatContext( + parentContext: FormatContext, +): FormatContext { + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + parentContext.tagScope | FALLBACK_SCOPE | EXIT_SCOPE, + getSuspenseViewTransition(parentContext.viewTransition), + ); +} + +export function getSuspenseContentFormatContext( + parentContext: FormatContext, +): FormatContext { + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + parentContext.tagScope | ENTER_SCOPE, + getSuspenseViewTransition(parentContext.viewTransition), + ); +} + +export function getViewTransitionFormatContext( + parentContext: FormatContext, + update: ?string, + enter: ?string, + exit: ?string, + share: ?string, + name: ?string, + autoName: string, // name or an autogenerated unique name +): FormatContext { + // We're entering a . Normalize props. + if (update == null) { + update = 'auto'; + } + if (enter == null) { + enter = 'auto'; + } + if (exit == null) { + exit = 'auto'; + } + if (name == null) { + const parentViewTransition = parentContext.viewTransition; + if (parentViewTransition !== null) { + // If we have multiple nested ViewTransition and the parent has a "share" + // but the child doesn't, then the parent ViewTransition can still activate + // a share scenario so we reuse the name and share from the parent. + name = parentViewTransition.name; + share = parentViewTransition.share; + } else { + name = 'auto'; + share = null; // share is only relevant if there's an explicit name + } + } else if (share === 'none') { + // I believe if share is disabled, it means the same thing as if it doesn't + // exit because enter/exit will take precedence and if it's deeply nested + // it just animates along whatever the parent does when disabled. + share = null; + } else if (share == null) { + share = 'auto'; + } + if (!(parentContext.tagScope & EXIT_SCOPE)) { + exit = null; // exit is only relevant for the first ViewTransition inside fallback + } + if (!(parentContext.tagScope & ENTER_SCOPE)) { + enter = null; // enter is only relevant for the first ViewTransition inside content + } + const viewTransition: ViewTransitionContext = { + update, + enter, + exit, + share, + name, + autoName, + nameIdx: 0, + }; + const subtreeScope = parentContext.tagScope & SUBTREE_SCOPE; + return createFormatContext( + parentContext.insertionMode, + parentContext.selectedValue, + subtreeScope, + viewTransition, + ); +} + export function isPreambleContext(formatContext: FormatContext): boolean { return formatContext.insertionMode === HTML_HEAD_MODE; } @@ -922,6 +1069,43 @@ export function pushSegmentFinale( } } +function pushViewTransitionAttributes( + target: Array, + formatContext: FormatContext, +): void { + if (!enableViewTransition) { + return; + } + const viewTransition = formatContext.viewTransition; + if (viewTransition === null) { + return; + } + if (viewTransition.name !== 'auto') { + pushStringAttribute( + target, + 'vt-name', + viewTransition.nameIdx === 0 + ? viewTransition.name + : viewTransition.name + '_' + viewTransition.nameIdx, + ); + // Increment the index in case we have multiple children to the same ViewTransition. + // Because this is a side-effect in render, we should ideally call pushViewTransitionAttributes + // after we've suspended (like forms do), so that we don't increment each attempt. + // TODO: Make this deterministic. + viewTransition.nameIdx++; + } + pushStringAttribute(target, 'vt-update', viewTransition.update); + if (viewTransition.enter !== null) { + pushStringAttribute(target, 'vt-enter', viewTransition.enter); + } + if (viewTransition.exit !== null) { + pushStringAttribute(target, 'vt-exit', viewTransition.exit); + } + if (viewTransition.share !== null) { + pushStringAttribute(target, 'vt-share', viewTransition.share); + } +} + const styleNameCache: Map = new Map(); function processStyleName(styleName: string): PrecomputedChunk { const chunk = styleNameCache.get(styleName); @@ -1054,6 +1238,7 @@ function pushStringAttribute( } function makeFormFieldPrefix(resumableState: ResumableState): string { + // TODO: Make this deterministic. const id = resumableState.nextFormID++; return resumableState.idPrefix + id; } @@ -1660,6 +1845,7 @@ function checkSelectProp(props: any, propName: string) { function pushStartAnchor( target: Array, props: Object, + formatContext: FormatContext, ): ReactNodeList { target.push(startChunkForTag('a')); @@ -1694,6 +1880,8 @@ function pushStartAnchor( } } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); pushInnerHTML(target, innerHTML, children); if (typeof children === 'string') { @@ -1708,6 +1896,7 @@ function pushStartAnchor( function pushStartObject( target: Array, props: Object, + formatContext: FormatContext, ): ReactNodeList { target.push(startChunkForTag('object')); @@ -1759,6 +1948,8 @@ function pushStartObject( } } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); pushInnerHTML(target, innerHTML, children); if (typeof children === 'string') { @@ -1773,6 +1964,7 @@ function pushStartObject( function pushStartSelect( target: Array, props: Object, + formatContext: FormatContext, ): ReactNodeList { if (__DEV__) { checkControlledValueProps('select', props); @@ -1826,6 +2018,8 @@ function pushStartSelect( } } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); pushInnerHTML(target, innerHTML, children); return children; @@ -1955,6 +2149,7 @@ function pushStartOption( target.push(selectedMarkerAttribute); } + // Options never participate as ViewTransitions. target.push(endOfStartTag); pushInnerHTML(target, innerHTML, children); return children; @@ -2024,6 +2219,7 @@ function pushStartForm( props: Object, resumableState: ResumableState, renderState: RenderState, + formatContext: FormatContext, ): ReactNodeList { target.push(startChunkForTag('form')); @@ -2133,6 +2329,8 @@ function pushStartForm( pushAttribute(target, 'target', formTarget); } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); if (formActionName !== null) { @@ -2157,6 +2355,7 @@ function pushInput( props: Object, resumableState: ResumableState, renderState: RenderState, + formatContext: FormatContext, ): ReactNodeList { if (__DEV__) { checkControlledValueProps('input', props); @@ -2286,6 +2485,8 @@ function pushInput( pushAttribute(target, 'value', defaultValue); } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTagSelfClosing); // We place any additional hidden form fields after the input. @@ -2299,6 +2500,7 @@ function pushStartButton( props: Object, resumableState: ResumableState, renderState: RenderState, + formatContext: FormatContext, ): ReactNodeList { target.push(startChunkForTag('button')); @@ -2370,6 +2572,8 @@ function pushStartButton( name, ); + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); // We place any additional hidden form fields we need to include inside the button itself. @@ -2389,6 +2593,7 @@ function pushStartButton( function pushStartTextArea( target: Array, props: Object, + formatContext: FormatContext, ): ReactNodeList { if (__DEV__) { checkControlledValueProps('textarea', props); @@ -2443,6 +2648,8 @@ function pushStartTextArea( value = defaultValue; } + pushViewTransitionAttributes(target, formatContext); + target.push(endOfStartTag); // TODO (yungsters): Remove support for children content in