JavaScript Regex test() Alternates: Reset or Remove Stateful Flags
If a reused /cat/g returns true, then false, then true for the same string, inspect lastIndex. For independent “does this string contain a match?” checks, remove g and y. If a stateful expression is intentional, set its cursor explicitly before each independent operation.
The failure can also cross input strings: an array filter may skip catfish after accepting cat. The executable example below traces that omission and compares resetting a global expression with resetting a sticky one.
The state belongs to the regex object
MDN documents test() as stateful when g or y is present. Changing the input string does not create a new cursor. A successful built-in match sets lastIndex to its end; a failed global or sticky search resets it to zero.
For /cat/g on cat, the first match ends at index 3. The next call starts there, finds no match, and resets the cursor. The third call starts at zero again. This particular input alternates; the general rule is cursor movement, rather than “every second call fails.” Multiple matches in one string can produce consecutive successes.
Adding anchors does not remove state. A reused /^cat$/g can still alternate on cat. Keep the anchors if you need a whole-string rule, but remove stateful flags for independent validation. The examples here test substring presence, not an email, identifier, or security validation policy.
Run a cursor trace and reproduce the skipped row
Save this as regex-state.mjs and run node regex-state.mjs. This fixture was executed with Node.js 24.13.0 on macOS during article preparation on October 21, 2026. It uses ordinary built-in regex objects and requires no dependencies.
import assert from 'node:assert/strict';
function trace(regex, inputs) { return inputs.map(input => { const before = regex.lastIndex; const matched = regex.test(input); return [input, before, matched, regex.lastIndex]; });}
const repeated = trace(/cat/g, ['cat', 'cat', 'cat']);assert.deepEqual(repeated, [ ['cat', 0, true, 3], ['cat', 3, false, 0], ['cat', 0, true, 3],]);console.log('repeated:', JSON.stringify(repeated));
const acrossInputs = trace(/cat/g, ['cat', 'catfish', 'cat']);assert.deepEqual(acrossInputs, [ ['cat', 0, true, 3], ['catfish', 3, false, 0], ['cat', 0, true, 3],]);console.log('different strings:', JSON.stringify(acrossInputs));
const rows = ['cat', 'catfish', 'bobcat'];const shared = /cat/g;const broken = rows.filter(value => shared.test(value));const fixed = rows.filter(value => /cat/.test(value));assert.deepEqual(broken, ['cat', 'bobcat']);assert.deepEqual(fixed, rows);console.log('shared global filter:', JSON.stringify(broken));console.log('stateless filter:', JSON.stringify(fixed));
const global = /cat/g;const sticky = /cat/y;const resetBeforeTest = regex => value => { regex.lastIndex = 0; return regex.test(value);};assert.deepEqual(rows.filter(resetBeforeTest(global)), rows);assert.deepEqual(rows.filter(resetBeforeTest(sticky)), ['cat', 'catfish']);console.log('reset global:', JSON.stringify(rows.filter(resetBeforeTest(global))));console.log('reset sticky:', JSON.stringify(rows.filter(resetBeforeTest(sticky))));
const plain = /cat/i;plain.lastIndex = 99;assert.equal(plain.test('CAT'), true);assert.equal(plain.lastIndex, 99);
const globalScan = /cat/g;const stickyScan = /cat/y;globalScan.lastIndex = stickyScan.lastIndex = 1;assert.equal(globalScan.test('xxcat'), true);assert.equal(globalScan.lastIndex, 5);assert.equal(stickyScan.test('xxcat'), false);assert.equal(stickyScan.lastIndex, 0);console.log('from index 1: global finds index 2; sticky fails and resets');console.log('plain /cat/i: lastIndex 99 is ignored and unchanged');Output:
repeated: [["cat",0,true,3],["cat",3,false,0],["cat",0,true,3]]different strings: [["cat",0,true,3],["catfish",3,false,0],["cat",0,true,3]]shared global filter: ["cat","bobcat"]stateless filter: ["cat","catfish","bobcat"]reset global: ["cat","catfish","bobcat"]reset sticky: ["cat","catfish"]from index 1: global finds index 2; sticky fails and resetsplain /cat/i: lastIndex 99 is ignored and unchangedEach trace row is [input, lastIndexBefore, matched, lastIndexAfter]. In the filter case, matching cat leaves the shared cursor at 3. Searching catfish from 3 misses its earlier match, so that row disappears. The failure resets the cursor before bobcat, which is retained.
The corrected filter uses a regex without either stateful flag. If filtering still removes unexpected values after this fix, inspect the predicate’s return value separately: truthiness filtering can discard valid zero and false values.
Choose the fix that preserves the intended search
The fixture verifies these choices against cat, catfish, and bobcat:
| Intended operation | Choice | Verified outcome |
|---|---|---|
| Independent substring check | /cat/, without g or y | All three strings match |
| Independent search with a reused global expression | Reset lastIndex = 0 immediately before each call | All three strings match |
| Independent match required at the beginning | Reset /cat/y to zero before each call | Only cat and catfish match |
| Continue scanning one input | Retain the cursor between calls | Search begins from the cursor; do not apply the independent-input reset |
The last row follows the ECMAScript RegExpBuiltinExec algorithm: a global search can advance beyond the starting position, while a sticky search must match at that position. The fixture checks this distinction from index 1 in xxcat: global finds the match at 2; sticky fails and resets.
Removing only g is insufficient when y remains. Conversely, removing y from a parser changes its matching rule. Resetting the sticky expression in this example preserves its requirement to match at position zero, which is why bobcat stays excluded. A parser continuing through one input should set or retain its actual cursor instead.
The lastIndex reference describes the cursor’s relationship to matching. Setting it is useful only when the operation consumes it: the fixture’s plain /cat/i ignores a stored value of 99 and leaves that property unchanged.
The reset helper deliberately mutates the supplied regex and leaves its resulting cursor in place. Use it only when this code owns that state; avoid sharing the object with another scanner that expects to resume from its previous position. For an ordinary presence check, a dedicated expression without g or y makes that ownership problem unnecessary.
These checks cover synchronous calls on ASCII strings in the stated Node environment. They do not test browser engines, overridden exec() methods, regex subclasses, Unicode cursor boundaries, or patterns that match empty strings. Before using a cursor-driven loop with another pattern, verify that the loop advances on every successful iteration; this fixture is a validation diagnostic, not a tokenizer.
References: