@mui/internal-docs-infra
Version:
MUI Infra - internal documentation creation tools.
295 lines (281 loc) • 13 kB
JavaScript
var _EditablePreview, _EditablePreview2, _EditablePreview3;
import * as React from 'react';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { userEvent } from 'vitest/browser';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { createParseSource } from "../pipeline/parseSource/index.mjs";
import { enhanceCodeEmphasis } from "../pipeline/enhanceCodeEmphasis/index.mjs";
import { Pre } from "./Pre.mjs";
import { preloadEditableEngine } from "./useEditable.mjs";
import { jsx as _jsx } from "react/jsx-runtime";
const FILE_NAME = 'CheckboxBasic.tsx';
const INITIAL_SOURCE = ["import * as React from 'react';", "import { Checkbox } from '@/components/Checkbox';", '', 'export default function CheckboxBasic() {', ' return (', ' <div>', ' <Checkbox defaultChecked />', " <p style={{ color: '#CA244D' }}>Type Whatever You Want Below</p>", ' </div>', ' );', '}'].join('\n');
const HIGHLIGHT_COMMENTS = {
7: ['@highlight-start'],
8: ['@highlight-end']
};
let parseSource;
beforeAll(async () => {
parseSource = await createParseSource();
// `<Pre>`'s editable path loads its editing engine on demand; warm it so
// editable renders below attach contentEditable synchronously (within `act`).
await preloadEditableEngine();
});
function createHighlightedSource(source) {
const root = parseSource(source, FILE_NAME);
return enhanceCodeEmphasis(root, HIGHLIGHT_COMMENTS, FILE_NAME);
}
/**
* Places the caret at a given character offset inside `element` and waits
* for one animation frame. `useEditable` captures its internal
* `state.position` from a `focus` listener via `requestAnimationFrame`,
* so synthesized keystrokes fired immediately after caret placement
* otherwise operate on the stale default `{line:0, column:0}`. Awaiting
* one frame here makes tests order-independent.
*/
async function placeCaret(element, offset) {
element.focus();
const selection = window.getSelection();
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let current = 0;
let node = walker.nextNode();
while (node) {
const length = node.textContent?.length ?? 0;
if (current + length >= offset) {
const range = document.createRange();
range.setStart(node, offset - current);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
break;
}
current += length;
node = walker.nextNode();
}
await new Promise(resolve => {
requestAnimationFrame(() => resolve());
});
}
/**
* `<Pre>` only hast-renders frames that the `IntersectionObserver` has
* marked visible (an optimization for large code blocks). The browser
* tests rely on every line having a `[data-ln]` span for caret math, so
* wait until the observer has hydrated all frames before driving input.
*/
async function waitForFramesHydrated(pre) {
await waitFor(() => {
const frames = pre.querySelectorAll('.frame');
expect(frames.length).toBeGreaterThan(0);
frames.forEach(frame => {
expect(frame.getAttribute('data-lined')).not.toBeNull();
});
});
}
function EditablePreview() {
const [source, setSource] = React.useState(INITIAL_SOURCE);
const highlightedSource = React.useMemo(() => createHighlightedSource(source), [source]);
return /*#__PURE__*/_jsx(Pre, {
fileName: FILE_NAME,
language: "tsx",
setSource: nextSource => setSource(nextSource),
shouldHighlight: true,
children: highlightedSource
});
}
describe('Pre - browser', () => {
// RTL's auto-cleanup is only wired up when the test framework exposes
// `globalThis.afterEach`. Vitest's browser runner doesn't, so we clean
// up explicitly to keep prior renders from pushing later tests' `<pre>`
// out of the viewport (which would prevent the IntersectionObserver
// from hydrating their frames).
afterEach(() => {
cleanup();
});
it('keeps the </p> line and following </div> line separate after typing and rerender', async () => {
render(_EditablePreview || (_EditablePreview = /*#__PURE__*/_jsx(EditablePreview, {})));
const pre = screen.getByText('Type Whatever You Want Below', {
exact: false
}).closest('pre');
expect(pre).not.toBeNull();
await waitForFramesHydrated(pre);
const lines = INITIAL_SOURCE.split('\n');
let offset = 0;
for (let i = 0; i < 7; i += 1) {
offset += lines[i].length + 1;
}
offset += lines[7].length;
await placeCaret(pre, offset);
await userEvent.keyboard('x');
await waitFor(() => {
const currentPre = screen.getByText('Type Whatever You Want Below', {
exact: false
}).closest('pre');
const highlightedLine = currentPre?.querySelector('[data-ln="8"]');
const nextLine = currentPre?.querySelector('[data-ln="9"]');
expect(highlightedLine).not.toBeNull();
expect(nextLine).not.toBeNull();
expect(highlightedLine.textContent).toContain('</p>x');
expect(highlightedLine.textContent).not.toContain('</div>');
expect(nextLine.textContent).toBe(' </div>');
});
});
it('keeps lines separate when typing two characters after </p> (Firefox repro)', async () => {
const {
container
} = render(_EditablePreview2 || (_EditablePreview2 = /*#__PURE__*/_jsx(EditablePreview, {})));
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
await waitForFramesHydrated(pre);
const lines = INITIAL_SOURCE.split('\n');
let offset = 0;
for (let i = 0; i < 7; i += 1) {
offset += lines[i].length + 1;
}
offset += lines[7].length;
await placeCaret(pre, offset);
// Type two characters with overlapping keydowns. The key events fire as:
// a-down, b-down, a-up, b-up — which means the second keydown arrives
// before React has rerendered from the first edit. Without correct
// pendingContent tracking, Firefox merges the </p> line into the
// following </div> line during the second keystroke (line 8 ends up as
// "...</p>ab </div>" and line 9 shifts up to " );").
await userEvent.keyboard('{a>}{b>}{/a}{/b}');
await waitFor(() => {
const currentPre = container.querySelector('pre');
const highlightedLine = currentPre.querySelector('[data-ln="8"]');
const nextLine = currentPre.querySelector('[data-ln="9"]');
expect(highlightedLine).not.toBeNull();
expect(nextLine).not.toBeNull();
expect(highlightedLine.textContent).toContain('</p>ab');
expect(highlightedLine.textContent).not.toContain('</div>');
expect(nextLine.textContent).toBe(' </div>');
});
});
it('edits correctly when surrounding frames are not hast-rendered (mixed visibility)', async () => {
// Build a tall source with two well-separated highlight regions and a
// long non-emphasised middle. The middle frame is `'normal'` (not in
// `COLLAPSED_VISIBLE_FRAME_TYPES`), so it starts as plain text and only
// hydrates if the `IntersectionObserver` reports it as visible. Most
// viewports won't reach the bottom highlight either, leaving it
// un-hydrated. We verify editing in a hydrated frame still produces a
// correct full-source update — i.e. the plain-text frames flow through
// `element.textContent` unchanged.
const middleFiller = Array.from({
length: 80
}, (_unused, index) => ` // filler line ${index + 1}`).join('\n');
const longSource = ["import * as React from 'react';", '', 'export default function TallDemo() {', " const top = 'top';", " const middle = 'middle';", '', middleFiller, '', " const bottom = 'bottom';", ' return null;', '}'].join('\n');
// Highlight one line near the top and one line near the bottom so the
// hast splits into: normal · highlighted · normal · highlighted · normal.
const totalLines = longSource.split('\n').length;
const longComments = {
4: ['@highlight'],
[totalLines - 2]: ['@highlight']
};
function TallEditablePreview() {
const [source, setSource] = React.useState(longSource);
const [captured, setCaptured] = React.useState(null);
const highlightedSource = React.useMemo(() => {
const root = parseSource(source, FILE_NAME);
return enhanceCodeEmphasis(root, longComments, FILE_NAME);
}, [source]);
// Expose the most-recent captured source via a data attribute so the
// test can read it without reaching into React internals.
return /*#__PURE__*/_jsx("div", {
"data-captured-source": captured ?? source,
children: /*#__PURE__*/_jsx(Pre, {
fileName: FILE_NAME,
language: "tsx",
setSource: nextSource => {
setCaptured(nextSource);
setSource(nextSource);
},
shouldHighlight: true,
children: highlightedSource
})
});
}
const {
container
} = render(/*#__PURE__*/_jsx(TallEditablePreview, {}));
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
// Wait until at least one frame is hydrated AND at least one frame is
// not — the mixed state we want to exercise. The IO callback runs on
// the next animation frame after observation, so this resolves quickly
// in real browsers.
await waitFor(() => {
const frames = Array.from(pre.querySelectorAll('.frame'));
expect(frames.length).toBeGreaterThanOrEqual(3);
const hydrated = frames.filter(frame => frame.getAttribute('data-lined') !== null);
const plainText = frames.filter(frame => frame.getAttribute('data-lined') === null);
expect(hydrated.length).toBeGreaterThan(0);
expect(plainText.length).toBeGreaterThan(0);
});
// Find the first hydrated `.line` (it should belong to the top
// highlighted frame, which is in viewport) and place the caret at its
// end so the next keystroke appends to it.
const firstHydratedLine = pre.querySelector('[data-ln]');
expect(firstHydratedLine).not.toBeNull();
const lineNumber = Number(firstHydratedLine.getAttribute('data-ln'));
expect(Number.isFinite(lineNumber)).toBe(true);
// Compute global offset to the end of that line in the source.
const sourceLines = longSource.split('\n');
let offset = 0;
for (let i = 0; i < lineNumber - 1; i += 1) {
offset += sourceLines[i].length + 1;
}
offset += sourceLines[lineNumber - 1].length;
await placeCaret(pre, offset);
await userEvent.keyboard('Z');
// After the edit, `setSource` must have received the complete source
// (including the long plain-text middle and the un-hydrated bottom
// highlight) with `Z` appended to the targeted line. `useEditable`
// normalises the captured text to always end with a newline.
await waitFor(() => {
const wrapper = container.firstElementChild;
const captured = wrapper.getAttribute('data-captured-source');
expect(captured).not.toBeNull();
const expectedLines = longSource.split('\n');
expectedLines[lineNumber - 1] = `${expectedLines[lineNumber - 1]}Z`;
const expected = `${expectedLines.join('\n')}\n`;
expect(captured).toBe(expected);
});
// Sanity-check: at least one frame is still rendered as plain text
// after the edit, confirming the optimization isn't bypassed by
// re-rendering.
const framesAfter = Array.from(pre.querySelectorAll('.frame'));
const plainTextAfter = framesAfter.filter(frame => frame.getAttribute('data-lined') === null);
expect(plainTextAfter.length).toBeGreaterThan(0);
});
it('does not collapse lines when deleting and re-typing the > in <div>', async () => {
const {
container
} = render(_EditablePreview3 || (_EditablePreview3 = /*#__PURE__*/_jsx(EditablePreview, {})));
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
await waitForFramesHydrated(pre);
// Place caret at the end of line 6: " <div>"
const lines = INITIAL_SOURCE.split('\n');
let offset = 0;
for (let i = 0; i < 5; i += 1) {
offset += lines[i].length + 1;
}
offset += lines[5].length; // end of " <div>"
await placeCaret(pre, offset);
// Delete the '>' and immediately re-type it without waiting for re-render
await userEvent.keyboard('{Backspace}>');
// Verify lines are not collapsed after the final re-render
await waitFor(() => {
const currentPre = container.querySelector('pre');
const line6 = currentPre.querySelector('[data-ln="6"]');
const line7 = currentPre.querySelector('[data-ln="7"]');
const line9 = currentPre.querySelector('[data-ln="9"]');
expect(line6).not.toBeNull();
expect(line7).not.toBeNull();
expect(line9).not.toBeNull();
expect(line6.textContent).toContain('<div>');
expect(line6.textContent).not.toContain('<Checkbox');
expect(line9.textContent).toContain('</div>');
});
});
});