Skip to content

[Fizz] Apply View Transition Name and Class to SSR:ed View Transitions #33332

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions fixtures/view-transition/server/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ export default function render(url, res) {
const {pipe, abort} = renderToPipeableStream(
<App assets={assets} initialURL={url} />,
{
// TODO: Temporary hack. Detect from attributes instead.
bootstrapScriptContent: 'window._useVT = true;',
bootstrapScripts: [assets['main.js']],
onShellReady() {
// If something errored before we started streaming, we set the error code appropriately.
Expand Down
48 changes: 34 additions & 14 deletions fixtures/view-transition/src/components/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,21 +200,41 @@ export default function Page({url, navigate}) {
<div>!!</div>
</ViewTransition>
</Activity>
<Suspense fallback="Loading">
<Suspense
fallback={
<ViewTransition>
<div>
<ViewTransition name="shared-reveal">
<h2>█████</h2>
</ViewTransition>
<p>████</p>
<p>███████</p>
<p>████</p>
<p>██</p>
<p>██████</p>
<p>███</p>
<p>████</p>
</div>
</ViewTransition>
}>
<ViewTransition>
<p>these</p>
<p>rows</p>
<p>exist</p>
<p>to</p>
<p>test</p>
<p>scrolling</p>
<p>content</p>
<p>out</p>
<p>of</p>
{portal}
<p>the</p>
<p>viewport</p>
<Suspend />
<div>
<p>these</p>
<p>rows</p>
<ViewTransition name="shared-reveal">
<h2>exist</h2>
</ViewTransition>
<p>to</p>
<p>test</p>
<p>scrolling</p>
<p>content</p>
<p>out</p>
<p>of</p>
{portal}
<p>the</p>
<p>viewport</p>
<Suspend />
</div>
</ViewTransition>
</Suspense>
{show ? <Component /> : null}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Shared implementation and constants between the inline script and external
// runtime instruction sets.

const ELEMENT_NODE = 1;
const COMMENT_NODE = 8;
const ACTIVITY_START_DATA = '&';
const ACTIVITY_END_DATA = '/&';
Expand Down Expand Up @@ -84,14 +85,168 @@ export function revealCompletedBoundariesWithViewTransitions(
revealBoundaries,
batch,
) {
let shouldStartViewTransition = false;
let autoNameIdx = 0;
const restoreQueue = [];
function applyViewTransitionName(element, classAttributeName) {
const className = element.getAttribute(classAttributeName);
if (!className) {
return;
}
// Add any elements we apply a name to a queue to be reverted when we start.
const elementStyle = element.style;
restoreQueue.push(
element,
elementStyle['viewTransitionName'],
elementStyle['viewTransitionClass'],
);
if (className !== 'auto') {
elementStyle['viewTransitionClass'] = className;
}
let name = element.getAttribute('vt-name');
if (!name) {
// Auto-generate a name for this one.
// TODO: We don't have a prefix to pick from here but maybe we don't need it
// since it's only applicable temporarily during this specific animation.
const idPrefix = '';
name = '\u00AB' + idPrefix + 'T' + autoNameIdx++ + '\u00BB';
}
elementStyle['viewTransitionName'] = name;
shouldStartViewTransition = true;
}
try {
const existingTransition = document['__reactViewTransition'];
if (existingTransition) {
// Retry after the previous ViewTransition finishes.
existingTransition.finished.finally(window['$RV'].bind(null, batch));
return;
}
const shouldStartViewTransition = window['_useVT']; // TODO: Detect.
// First collect all entering names that might form pairs exiting names.
const appearingViewTransitions = new Map();
for (let i = 1; i < batch.length; i += 2) {
const contentNode = batch[i];
const appearingElements = contentNode.querySelectorAll('[vt-share]');
for (let j = 0; j < appearingElements.length; j++) {
const appearingElement = appearingElements[j];
appearingViewTransitions.set(
appearingElement.getAttribute('vt-name'),
appearingElement,
);
}
}
// Next we'll find the nodes that we're going to animate and apply names to them..
for (let i = 0; i < batch.length; i += 2) {
const suspenseIdNode = batch[i];
const parentInstance = suspenseIdNode.parentNode;
if (!parentInstance) {
// We may have client-rendered this boundary already. Skip it.
continue;
}
const parentRect = parentInstance.getBoundingClientRect();
if (
!parentRect.left &&
!parentRect.top &&
!parentRect.width &&
!parentRect.height
) {
// If the parent instance is display: none then we don't animate this boundary.
// This can happen when this boundary is actually a child of a different boundary that
// isn't yet revealed or is about to be revealed, but in that case that boundary
// should do the exit/enter and not this one. Conveniently this also lets us skip
// this if it's just in a hidden tree in general.
// TODO: Should we skip it if it's out of viewport? It's possible that it gets
// brought into the viewport by changing size.
// TODO: There's a another case where an inner boundary is inside a fallback that
// is about to be deleted. In that case we should not run exit animations on the inner.
continue;
}

// Apply exit animations to the immediate elements inside the fallback.
let node = suspenseIdNode;
let depth = 0;
while (node) {
if (node.nodeType === COMMENT_NODE) {
const data = node.data;
if (data === SUSPENSE_END_DATA) {
if (depth === 0) {
break;
} else {
depth--;
}
} else if (
data === SUSPENSE_START_DATA ||
data === SUSPENSE_PENDING_START_DATA ||
data === SUSPENSE_QUEUED_START_DATA ||
data === SUSPENSE_FALLBACK_START_DATA
) {
depth++;
}
} else if (node.nodeType === ELEMENT_NODE) {
const exitElement = node;
const exitName = exitElement.getAttribute('vt-name');
const pairedElement = appearingViewTransitions.get(exitName);
applyViewTransitionName(
exitElement,
pairedElement ? 'vt-share' : 'vt-exit',
);
if (pairedElement) {
// Activate the other side as well.
applyViewTransitionName(pairedElement, 'vt-share');
appearingViewTransitions.set(exitName, null); // mark claimed
}
// Next we'll look inside this element for pairs to trigger "share".
const disappearingElements =
exitElement.querySelectorAll('[vt-share]');
for (let j = 0; j < disappearingElements.length; j++) {
const disappearingElement = disappearingElements[j];
const name = disappearingElement.getAttribute('vt-name');
const appearingElement = appearingViewTransitions.get(name);
if (appearingElement) {
applyViewTransitionName(disappearingElement, 'vt-share');
applyViewTransitionName(appearingElement, 'vt-share');
appearingViewTransitions.set(name, null); // mark claimed
}
}
}
node = node.nextSibling;
}

// Apply enter animations to the new nodes about to be inserted.
const contentNode = batch[i + 1];
let enterElement = contentNode.firstElementChild;
while (enterElement) {
const paired =
appearingViewTransitions.get(enterElement.getAttribute('vt-name')) ===
null;
if (!paired) {
applyViewTransitionName(enterElement, 'vt-enter');
}
enterElement = enterElement.nextElementSibling;
}

// Apply update animations to any parents and siblings that might be affected.
let ancestorElement = parentInstance;
do {
let childElement = ancestorElement.firstElementChild;
while (childElement) {
// TODO: Bail out if we can
const updateClassName = childElement.getAttribute('vt-update');
if (
updateClassName &&
updateClassName !== 'none' &&
!restoreQueue.includes(childElement)
) {
// If we have already handled this element as part of another exit/enter/share, don't override.
applyViewTransitionName(childElement, 'vt-update');
}
childElement = childElement.nextElementSibling;
}
} while (
(ancestorElement = ancestorElement.parentNode) &&
ancestorElement.nodeType === ELEMENT_NODE &&
ancestorElement.getAttribute('vt-update') !== 'none'
);
}
if (shouldStartViewTransition) {
const transition = (document['__reactViewTransition'] = document[
'startViewTransition'
Expand All @@ -100,7 +255,19 @@ export function revealCompletedBoundariesWithViewTransitions(
types: [], // TODO: Add a hard coded type for Suspense reveals.
}));
transition.ready.finally(() => {
// TODO
// Restore all the names/classes that we applied to what they were before.
// We do it in reverse order in case there were duplicates so the first one wins.
for (let i = restoreQueue.length - 3; i >= 0; i -= 3) {
const element = restoreQueue[i];
const elementStyle = element.style;
const previousName = restoreQueue[i + 1];
elementStyle['viewTransitionName'] = previousName;
const previousClassName = restoreQueue[i + 1];
elementStyle['viewTransitionClass'] = previousClassName;
if (element.getAttribute('style') === '') {
element.removeAttribute('style');
}
}
});
transition.finished.finally(() => {
if (document['__reactViewTransition'] === transition) {
Expand Down
Loading