'use strict';
import {
insertNodesAndExecuteScripts,
getVisibleChildren,
} from '../test-utils/FizzTestUtils';
let JSDOM;
let React;
let Suspense;
let ViewTransition;
let ReactDOMClient;
let clientAct;
let ReactDOMFizzServer;
let Stream;
let document;
let writable;
let container;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
describe('ReactDOMFizzViewTransition', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
React = require('react');
ReactDOMClient = require('react-dom/client');
clientAct = require('internal-test-utils').act;
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
ViewTransition = React.ViewTransition;
const jsdom = new JSDOM(
'<!DOCTYPE html><html><head></head><body><div id="container">',
{
runScripts: 'dangerously',
},
);
document = jsdom.window.document;
container = document.getElementById('container');
global.window = jsdom.window;
global.requestAnimationFrame = global.window.requestAnimationFrame = cb =>
setTimeout(cb);
buffer = '';
hasErrored = false;
writable = new Stream.PassThrough();
writable.setEncoding('utf8');
writable.on('data', chunk => {
buffer += chunk;
});
writable.on('error', error => {
hasErrored = true;
fatalError = error;
});
});
afterEach(() => {
jest.restoreAllMocks();
});
async function serverAct(callback) {
await callback();
await new Promise(resolve => {
setImmediate(resolve);
});
if (hasErrored) {
throw fatalError;
}
const bufferedContent = buffer;
buffer = '';
const temp = document.createElement('body');
temp.innerHTML = bufferedContent;
await insertNodesAndExecuteScripts(temp, container, null);
jest.runAllTimers();
}
it('emits annotations for view transitions', async () => {
function App() {
return (
<div>
<ViewTransition>
<div />
</ViewTransition>
<ViewTransition name="foo" update="bar">
<div />
</ViewTransition>
<ViewTransition update={{something: 'a', default: 'baz'}}>
<div />
</ViewTransition>
<ViewTransition name="outer" update="bar" share="pair">
<ViewTransition>
<div />
</ViewTransition>
</ViewTransition>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-update="auto" />
<div vt-name="foo" vt-update="bar" vt-share="auto" />
<div vt-update="baz" />
<div vt-name="outer" vt-update="auto" vt-share="pair" />
</div>,
);
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
});
it('emits enter/exit annotations for view transitions inside Suspense', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition>
<div>
<ViewTransition>
<span>Loading</span>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<ViewTransition>
<Suspend />
</ViewTransition>
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-update="auto" vt-exit="auto">
<span vt-update="auto">Loading</span>
</div>
</div>,
);
await serverAct(async () => {
await resolve(
<div>
<ViewTransition>
<span>Content</span>
</ViewTransition>
</div>,
);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-update="auto" vt-enter="auto">
<span vt-update="auto">Content</span>
</div>
</div>,
);
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
});
it('can emit both enter and exit on the same node', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<Suspense fallback={null}>
<ViewTransition enter="hello" exit="goodbye">
<div>
<ViewTransition>
<span>Loading</span>
</ViewTransition>
</div>
</ViewTransition>
</Suspense>
);
return (
<div>
<Suspense fallback={fallback}>
<ViewTransition enter="hi">
<Suspend />
</ViewTransition>
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-update="auto" vt-enter="hello" vt-exit="goodbye">
<span vt-update="auto">Loading</span>
</div>
</div>,
);
await serverAct(async () => {
await resolve(
<div>
<ViewTransition>
<span>Content</span>
</ViewTransition>
</div>,
);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-update="auto" vt-enter="hi">
<span vt-update="auto">Content</span>
</div>
</div>,
);
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
});
it('emits annotations for view transitions outside Suspense', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<div>
<ViewTransition>
<span>Loading</span>
</ViewTransition>
</div>
);
return (
<div>
<ViewTransition>
<Suspense fallback={fallback}>
<Suspend />
</Suspense>
</ViewTransition>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-name="_R_0_" vt-update="auto" vt-share="auto">
<span vt-update="auto">Loading</span>
</div>
</div>,
);
await serverAct(async () => {
await resolve(
<div>
<ViewTransition>
<span>Content</span>
</ViewTransition>
</div>,
);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div vt-name="_R_0_" vt-update="auto" vt-share="auto">
<span vt-update="auto">Content</span>
</div>
</div>,
);
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
});
it('stops the parentExit relay when an intermediate class is "none"', async () => {
const promise = new Promise(() => {});
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition exit="page-exit">
<div id="A">
<ViewTransition parentExit="none">
<div id="B">
<ViewTransition parentExit="nested-exit">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
<ViewTransition parentExit="nested-exit">
<span id="D">Shallow</span>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<Suspend />
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-exit="page-exit">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto">
Deep
</div>
</div>
<span id="D" vt-update="auto" vt-parent-exit="nested-exit">
Shallow
</span>
</div>
</div>,
);
});
it('stops the parentEnter relay when an intermediate class is "none"', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
return (
<div>
<Suspense fallback={<div>loading</div>}>
<ViewTransition enter="page-enter">
<Suspend />
</ViewTransition>
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
await serverAct(async () => {
await resolve(
<div id="A">
<ViewTransition parentEnter="none">
<div id="B">
<ViewTransition parentEnter="nested-enter">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
<ViewTransition parentEnter="nested-enter">
<span id="D">Shallow</span>
</ViewTransition>
</div>,
);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-enter="page-enter">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto">
Deep
</div>
</div>
<span id="D" vt-update="auto" vt-parent-enter="nested-enter">
Shallow
</span>
</div>
</div>,
);
await clientAct(async () => {
ReactDOMClient.hydrateRoot(container, <App />);
});
});
it('breaks the parentExit relay through a ViewTransition without parentExit', async () => {
const promise = new Promise(() => {});
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition exit="page-exit">
<div id="A">
<ViewTransition>
<div id="B">
<ViewTransition parentExit="nested-exit">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<Suspend />
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-exit="page-exit">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto">
Deep
</div>
</div>
</div>
</div>,
);
});
it('relays the parentExit chain through an "auto" parentExit', async () => {
const promise = new Promise(() => {});
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition exit="page-exit">
<div id="A">
<ViewTransition parentExit="auto">
<div id="B">
<ViewTransition parentExit="nested-exit">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<Suspend />
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-exit="page-exit">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto" vt-parent-exit="nested-exit">
Deep
</div>
</div>
</div>
</div>,
);
});
it('relays the parentExit chain through a handler-only ViewTransition', async () => {
const promise = new Promise(() => {});
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition exit="page-exit">
<div id="A">
<ViewTransition onParentExit={() => {}}>
<div id="B">
<ViewTransition parentExit="nested-exit">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<Suspend />
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-exit="page-exit">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto" vt-parent-exit="nested-exit">
Deep
</div>
</div>
</div>
</div>,
);
});
it('relays the parentEnter chain through a handler-only ViewTransition', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
return (
<div>
<Suspense fallback={<div>loading</div>}>
<ViewTransition enter="page-enter">
<Suspend />
</ViewTransition>
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
await serverAct(async () => {
await resolve(
<div id="A">
<ViewTransition onParentEnter={() => {}}>
<div id="B">
<ViewTransition parentEnter="nested-enter">
<div id="C">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
</div>,
);
});
expect(getVisibleChildren(container)).toEqual(
<div>
<div id="A" vt-update="auto" vt-enter="page-enter">
<div id="B" vt-update="auto">
<div id="C" vt-update="auto" vt-parent-enter="nested-enter">
Deep
</div>
</div>
</div>
</div>,
);
});
it('applies view-transition-name to nested parentEnter/parentExit on streaming reveal', async () => {
const applied = new Map();
document.startViewTransition = function (arg) {
const update = typeof arg === 'function' ? arg : arg.update;
container.querySelectorAll('*').forEach(el => {
if (el.id && el.style && el.style.viewTransitionName) {
applied.set(el.id, el.style.viewTransitionClass);
}
});
if (update) {
update();
}
return {
ready: Promise.resolve(),
finished: Promise.resolve(),
skipTransition() {},
types: [],
};
};
if (!global.window.CSS) {
global.window.CSS = {escape: s => s};
}
Object.defineProperty(document, 'fonts', {
value: {status: 'loaded', ready: Promise.resolve()},
configurable: true,
});
global.window.Element.prototype.getBoundingClientRect = function () {
return {left: 0, top: 0, width: 100, height: 20, right: 100, bottom: 20};
};
let resolve;
const promise = new Promise(r => (resolve = r));
function Suspend() {
return React.use(promise);
}
function App() {
const fallback = (
<ViewTransition exit="page-exit">
<div id="fbA">
<ViewTransition parentExit="skeleton-exit">
<div id="fbC">
<ViewTransition parentExit="skeleton-exit-deep">
<div id="fbD">Loading</div>
</ViewTransition>
</div>
</ViewTransition>
</div>
</ViewTransition>
);
return (
<div>
<Suspense fallback={fallback}>
<ViewTransition enter="page-enter">
<Suspend />
</ViewTransition>
</Suspense>
</div>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
await serverAct(async () => {
await resolve(
<div id="A">
<ViewTransition parentEnter="nested-enter">
<div id="C">
<ViewTransition parentEnter="nested-enter-deep">
<div id="D">Deep</div>
</ViewTransition>
</div>
</ViewTransition>
</div>,
);
});
expect(applied.get('A')).toBe('page-enter');
expect(applied.get('C')).toBe('nested-enter');
expect(applied.get('D')).toBe('nested-enter-deep');
expect(applied.get('fbA')).toBe('page-exit');
expect(applied.get('fbC')).toBe('skeleton-exit');
expect(applied.get('fbD')).toBe('skeleton-exit-deep');
});
});