Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,20 @@ test.describe('Components', () => {
window.REACT_DOM_DEVTOOLS;
const container = document.getElementById('devtools');

const element = findAllNodes(container, [
// The current result index is an editable input, so its value is not
// part of the wrapper's innerText. Combine the input value with the
// total result count to reconstruct the "X | Y" label.
const indexInput = findAllNodes(container, [
createTestNameSelector('ComponentSearchInput-ResultIndexInput'),
])[0];
const resultsCount = findAllNodes(container, [
createTestNameSelector('ComponentSearchInput-ResultsCount'),
])[0];
return element !== undefined
? element.innerText === expectedElementText
: false;
if (indexInput === undefined || resultsCount === undefined) {
return false;
}
const totalCount = resultsCount.innerText.replace(/[^0-9]/g, '');
return `${indexInput.value} | ${totalCount}` === expectedElementText;
}, text);
}

Expand Down
166 changes: 166 additions & 0 deletions packages/react-devtools-shared/src/__tests__/treeContext-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,172 @@ describe('TreeListContext', () => {
`);
});

it('should jump directly to a specific search result by index', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;

utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);

let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));

// search for "ba" (matches both <Baz> elements and <Bar>)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);

// jump directly to the third result
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 2}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);

// jump directly back to the first result
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);

// out-of-range indices are clamped to the valid range
utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 99}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);

utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: -5}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
→ <Baz>
<Bar>
<Baz>
`);
});

it('should do nothing when jumping to a result with no search matches', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;

utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);

let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));

utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'nomatch'}));
utils.act(() => renderer.update(<Contexts />));
expect(state.searchResults).toHaveLength(0);
expect(state.searchIndex).toBe(null);

utils.act(() => dispatch({type: 'GO_TO_SEARCH_RESULT', payload: 0}));
utils.act(() => renderer.update(<Contexts />));
expect(state.searchIndex).toBe(null);
expect(state.inspectedElementID).toBe(null);
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
<Baz>
`);
});

it('should advance past the selected result when retyping the same search', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;

utils.act(() =>
render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
),
);

let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));

// search for "ba" and step to the second result (<Bar>)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => dispatch({type: 'GO_TO_NEXT_SEARCH_RESULT'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
→ <Bar>
<Baz>
`);

// clear the search; the matched element stays selected
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: ''}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
→ <Bar>
<Baz>
`);

// retype the same query: instead of snapping back to the still-selected
// <Bar>, the search advances to the next match (find-next semantics)
utils.act(() => dispatch({type: 'SET_SEARCH_TEXT', payload: 'ba'}));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchInlineSnapshot(`
[root]
<Foo>
<Baz>
<Bar>
→ <Baz>
`);
});

it('should add newly mounted elements to the search results set if they match the current text', async () => {
const Foo = () => null;
const Bar = () => null;
Expand Down
59 changes: 59 additions & 0 deletions packages/react-devtools-shared/src/__tests__/utils-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
getDisplayName,
getDisplayNameForReactElement,
isPlainObject,
printOperationsArray,
} from 'react-devtools-shared/src/utils';
import {TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE} from 'react-devtools-shared/src/constants';
import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils';
import {
formatConsoleArguments,
Expand Down Expand Up @@ -147,6 +149,20 @@ describe('utils', () => {
).toEqual('a 123 b true c');
});

it('should support integer substitutions', () => {
expect(formatConsoleArgumentsToSingleString('%i', 3.14)).toEqual('3');
});

it('should support float substitutions', () => {
expect(formatConsoleArgumentsToSingleString('%f', 3.5)).toEqual('3.5');
});

it('should keep argument alignment across mixed substitutions', () => {
expect(formatConsoleArgumentsToSingleString('a %i b %s', 7, 'x')).toEqual(
'a 7 b x',
);
});

it('should gracefully handle Symbol types', () => {
expect(
formatConsoleArgumentsToSingleString(Symbol('a'), 'b', Symbol('c')),
Expand Down Expand Up @@ -509,5 +525,48 @@ function f() { }
]);
expect(formatConsoleArguments('%s 100%', 'done')).toEqual(['done 100%']);
});

it('keeps specifiers literal when no argument is supplied', () => {
expect(formatConsoleArguments('%s %s', 'the')).toEqual(['the %s']);
expect(formatConsoleArguments('%s %d', 'value')).toEqual(['value %d']);
expect(formatConsoleArguments('%s %i', 'value')).toEqual(['value %i']);
expect(formatConsoleArguments('%s %f', 'value')).toEqual(['value %f']);
});
});

describe('printOperationsArray', () => {
let log;
beforeEach(() => {
log = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
log.mockRestore();
});

// The operation is [opcode, activitySliceID] (2 slots). A trailing operation
// after it verifies that the reader advances past the value slot instead of
// re-reading it as the next opcode.
it('should log an applied activity slice change and advance past its value', () => {
const rendererID = 1;
const rootID = 1;
const stringTableSize = 0;
const operations = [
rendererID,
rootID,
stringTableSize,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
42,
TREE_OPERATION_APPLIED_ACTIVITY_SLICE_CHANGE,
0,
];

expect(() => printOperationsArray(operations)).not.toThrow();

expect(log).toHaveBeenCalledTimes(1);
expect(log.mock.calls[0][0]).toContain(
'Applied activity slice change to 42',
);
expect(log.mock.calls[0][0]).toContain('Reset applied activity slice');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,36 @@ export default function formatConsoleArguments(
}
case 'd':
case 'i': {
if (argumentsPointer >= args.length) {
// No argument left for this specifier. Keep it as a literal, like
// the browser console does, rather than emitting 'NaN'.
template += `%${nextChar}`;
break;
}
const [arg] = args.splice(argumentsPointer, 1);
template += parseInt(arg, 10).toString();

break;
}
case 'f': {
if (argumentsPointer >= args.length) {
// No argument left for this specifier. Keep it as a literal, like
// the browser console does, rather than emitting 'NaN'.
template += `%${nextChar}`;
break;
}
const [arg] = args.splice(argumentsPointer, 1);
template += parseFloat(arg).toString();

break;
}
case 's': {
if (argumentsPointer >= args.length) {
// No argument left for this specifier. Keep it as a literal, like
// the browser console does, rather than emitting 'undefined'.
template += `%${nextChar}`;
break;
}
const [arg] = args.splice(argumentsPointer, 1);
template += String(arg);

Expand Down
2 changes: 1 addition & 1 deletion packages/react-devtools-shared/src/backend/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function formatConsoleArgumentsToSingleString(
// If the first argument is a string, check for substitutions.
if (typeof maybeMessage === 'string') {
if (args.length) {
const REGEXP = /(%?)(%([jds]))/g;
const REGEXP = /(%?)(%([jdisf]))/g;

// $FlowFixMe[incompatible-type]
formatted = formatted.replace(REGEXP, (match, escaped, ptn, flag) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ export default function ComponentSearchInput(): React.Node {
() => transitionDispatch({type: 'GO_TO_PREVIOUS_SEARCH_RESULT'}),
[transitionDispatch],
);
const goToResult = useCallback(
(index: number) =>
transitionDispatch({type: 'GO_TO_SEARCH_RESULT', payload: index}),
[transitionDispatch],
);

return (
<SearchInput
goToNextResult={goToNextResult}
goToPreviousResult={goToPreviousResult}
goToResult={goToResult}
placeholder="Search (text or /regex/)"
search={search}
searchIndex={searchIndex}
Expand Down
Loading
Loading