As mentioned in title changing selectionStart has no effect when using userEvent.type(input, "foobar"), eg:
test("selectionStart is not used when updating input", async () => {
const { getByTestId } = render(<App />);
await waitForElement(() => getByTestId("testinput"));
const input = getByTestId("testinput");
expect(input).not.toBeNull();
expect(input.value).toBe("xyz");
// move cursor to the beginning of input
input.focus();
input.setSelectionRange(0, 0);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(0);
// start typing
await userEvent.type(input, "abc");
expect(input.value).toBe("abcxyz"); // <-- FAILS
expect(input.selectionStart).toBe(3);
});
Important: in the test case I use react-scripts test --env=jsdom-sixteen as jsdom had fixed selectionStart only recently https://github.com/jsdom/jsdom/issues/2787
With a quick look, I see a few things:
You need to place
/**
* @jest-environment jsdom-sixteen
*/
at the very top of your test file (above any imports) for jest to pick it up. However, that doesn't fix your tests breaking.
it seems like userEvent.type works by adding any newly typed text to the end of any previous text (relevant code). If you change the line
expect(input.value).toBe("abcxyz")
to
expect(input.value).toBe("xyzabc")
You'll see that particular assertion pass because of this behavior.
On related note, do you know if CodeSandbox respects the jsdom env you set? I believe that they just auto detect files with .test.js (or .spec files) and run them automatically. Not sure how they handle the jest environment. For example, if you remove the test script in your package.json the tests still run. That may be the reason your final test fails. I'd try running it locally as well.
In code sandbox I've set my test script to be react-scripts test --env=jsdom-sixteen, otherwise it does not use it.
BTW I'm aware that values are appended and then selectionStart is set to whatever index is at the end but in my case I need to test the case when user prepends the text. By looking into the source I've seen that logic does not handle this case, does not check for selectionStart position before writing text value, but there is an argument that it should.
Sure I could see that being helpful.
Maybe you could work around it by firing keyboard events with fireEvent instead?
Initially I played with keyPress, keyDown, keyUp, change separately with no success.
Confusing bit is that I'm not even sure what actually moves the cursor in Jest, do I need to fire all those events in sequence like implemented here in async type(et, text, options) method https://github.com/testing-library/user-event/blob/master/src/index.js#L178? With this in mind I thought that implementation should be here as it already does heavy lifting of this sequence.
Thought that simple change to type method would make it work but it does not. Above codesandbox example was extended to contain additional test with changed type method that checks selectionStart - but with no luck.
Seems that all those events (keyDown, keyPress, ...) do not really change selectionStart and this is more of an issue for jsdom.
I'm still not sure that codesandbox is using the correct jsdom environment when it runs your tests. I have a local test running with jsdom-sixteen where the selectionStart value updates correctly while using the existing type method.
This is the test I'm running successfully:
/**
* @jest-environment jsdom-sixteen
*/
import React, { useState } from "react";
import { render } from "@testing-library/react";
import user from "@testing-library/user-event";
function Input() {
const inputId = "testInput";
const [value, setValue] = useState("");
const updateValue = e => {
setValue(e.target.value);
};
return (
<>
<label htmlFor={inputId}>Test Input</label>
<input id={inputId} type={"text"} value={value} onChange={updateValue} />
</>
);
}
test("selectionStart updates", async () => {
const { getByLabelText } = render(<Input />);
const input = getByLabelText(/test input/i);
await user.type(input, "abc");
expect(input.value).toBe("abc");
expect(input.selectionStart).toBe(3);
});
That doesn't fix the behavior of respecting the existing selection start though.
I did have an idea of how to accomplish this though, and was able to modify the type method in the node_modules package to accept an additional parameter of startIndex, which is where you want the typing to start.
I got a rough version of it working with a test that looks like this:
test("text is entered correctly and selection start updates", async () => {
const { getByLabelText } = render(<Input />);
const input = getByLabelText(/test input/i);
await user.type(input, "world");
expect(input.value).toBe("world");
expect(input.selectionStart).toBe(5);
await user.type(input, "!");
expect(input.value).toBe("world!");
expect(input.selectionStart).toBe(6); // selectionStart is after the `!`
await user.type(input, "hello ", { startIndex: 0 });
expect(input.value).toBe("hello world!");
expect(input.selectionStart).toBe(6); // selectionStart is after the the space
});
If this an api / method people like I'd happily submit a PR to enable this behavior. Otherwise I'd be happy to brainstorm another api to enable the behavior too.
You are right, my over-reliance on Codesandbox confused a bit, but the gist is still same.
This is the test I'm running successfully:
Correct, selectionStart changes when using jsdom-sixteen, but only to the end position. One cannot "prepend" text by just changing selectionStart before typing into field with a value.
I'd happily submit a PR to enable this behavior
In my humble opinion it should not be necessary as its a wrong location to implement it in. And even if it allows partial input text change, there is still one thing that cannot be fixed - final selectionStart location which is currently always set to the end of input (from jsdom source):
https://github.com/jsdom/jsdom/blob/master/lib/jsdom/living/nodes/HTMLInputElement-impl.js#L408
Given my example: when input contains "xyz" and selectionStart is set to 0 then after typing "abc" the result should look like "abcxyz" and selectionStart at 3. To my mind your implementation cannot fix the last bit and selectionStart will always be 6 due to jsdom implementation.
I stand to be corrected but looks like this issue needs to be closed as "Won't fix".
Yes it looks like the jsdom implementation does set selectionStart to the end of the value. I was not aware of that.
I did actually account for it within the type method when firing the input updates, so that the values update correctly and are not always set to the input length:
if (pressEvent && !isTextPastThreshold) {
actuallyTyped += key;
const newIndex = ++currSelectionStart; // currSelectionStart initialized before looping through the input text
if (!element.readOnly) _dom.fireEvent.input(element, {
target: {
value: actuallyTyped + postStartIndexText,
selectionStart: newIndex, // added
selectionEnd: newIndex // added
},
bubbles: true,
cancelable: true
});
}
With this setup, things stay in sync while the typing is happening. It was perhaps confusing in my test above that the selectionStart remains at 6, but it is updating correctly.
So, it seems to me that it would be ideal if jsdom handled the selectionStart/End in a different way, but we could possibly work around it until that happens.
Looks simple enough but not clear what selectionStart and selectionEnd do in the event target? AFAIK those are getters/setters for input. Apart from it this feels like replication of logic defined in HTML spec, specifically the logic of setRangeText(replacement, start, end, selectMode) method. Could we not use input.setRangeText("x", start, end, "end") instead of input.value = input.value + "x"
Does the behaviour change when you set allAtOnce to false? @ivarpruudnikov that could only potentially fix it when you use allAtOnce to true which I barely use myself.
I have to admit I don't recognise much anymore of the type()-method I wrote in the past.
@weyert not really there is no real difference, in both cases selectionStart, selectionEnd is not used and text is appended to the end of existing value.
Hmm, I just wished you could just sent the letter to jsdom and let it append it to the value but that doesn't seem possible. I am wondering how an actual browser deals with this though. How regarding the event flows? Does it call setRangeText somehow? Anyways I have been lately working on a new version of type() but would be great to have this sorted before this new version.
This is now supported :)
Most helpful comment
This is now supported :)