Notes › Tech Notes › The Regex Test Page Always Forces g: gg Errors Out, Uppercase Fails Silently, and 21 Grouped Matches List Only 20 Rows

The Regex Test Page Always Forces g: gg Errors Out, Uppercase Fails Silently, and 21 Grouped Matches List Only 20 Rows

The flags box is washed by a six-letter whitelist and then forced to carry g: typing GIM fails quietly, typing gg throws on the spot. The status line, the highlight marks and the group panel keep separate counts, so 21 grouped matches still list only 20 rows.

The Flags Field Says GIM, but the Page Acts as If It Were Empty

It started when I typed uppercase GIM into the flags box, A into the pattern field and three a into the test text: the status line answered no match at all. Judging by the three letters sitting in that box, case-insensitive matching should have been on long before. The order of operations only became visible after I lifted the whole script section out of the template (6118 bytes / 133 lines) and ran it once under plain Node: L60 washes the input first, and L69 then unconditionally appends one g.

The wash follows a whitelist of just 6 letters — g i m s u y. Uppercase, spaces, symbols and Chinese characters are deleted before construction. That rule explains the surprises: type x and you land on g; type GIM and you also land on g; type giz and only gi survives; type vi and the v is deleted, leaving ig; gimsuy is the only input that passes through untouched. The appending step is even more rigid: empty becomes the real argument g; i becomes ig; imsuy becomes imsuyg, and re.flags is normalized by the engine into gimsuy. You can reproduce the wash-then-append routine by hand on the regular expression tester page.

gg meets a different fate: no de-duplication, no graceful ignoring. Construction throws Invalid flags supplied to RegExp constructor on the spot, the whole page switches into the red-cross branch (U+274C), and the result panel goes display:none. The variant with a space in the middle gets washed into gg first, so the error is a duplicate that the character-washing step manufactured by itself.

flags box -> washed once by the whitelist -> real argument after appending g You type After wash Sent into RegExp GIM empty g giz gi gi vi i ig imsuy imsuy imsuyg gg gg throws on the spot

Figure 1: what gets washed away, what stays, and the one appended letter — the argument handed to the constructor always contains g.

The Appended g Is Not a Feature Switch, It Keeps the matchAll Line Alive

The motivation can be nailed to a single line of code: the result panel runs text.matchAll(re), and a non-global object fed into it raises TypeError immediately; the measured message reads verbatim, "String.prototype.matchAll called with a non-global RegExp argument". The same /a/ against aaa throws the moment it is expanded without g, and yields 3 entries with g. So the appended g is a survival trick for that one line, not a switch left for the user to operate. The remaining flags i m s u y really are toggleable: pattern A against aaa gives 0 entries without i and 3 entries with i; ^b against text spanning several lines gives 0 without m and 1 with m. Only g is permanently true and permanently impossible to turn off.

Three Panels, Three Separate Ledgers

The number on the status line is the length of the array after matchAll expands; the <mark> shells of the highlight repeat once per element of that same array; and the group panel keeps its own books with a separate cap of 20 entries. Push the pattern (a) against longer and longer runs of a and the three ledgers split apart:

Match countStatus lineHighlight mark countGroup panelPanel list innerHTML
21212120 rows6021 characters
25252520 rows6021 characters
40404020 rows6021 characters
10001000100020 rows6021 characters
10000100001000020 rows6021 characters

The last row of the panel always stops at #20 index=19, the list never gains one extra entry, and nowhere on the page is there any text saying that only the first results are shown. Does the status line under-report? No — it writes 10000 without blinking; the 9980 truncated entries simply never appear in the group column.

Zero-Width Cases Are Where the Two Ledgers Show Their Difference

For (?:) against abc (3 visible characters) the status line writes that it found 4 matches drawing 4 marks with empty content, and the highlight HTML runs 419 characters; \b against a b c reports 6 matches and 6 empty marks; a* against baab reports 4 matches, of which 3 are zero-width and only 1 actually carries aa. The reported number and the count of green blocks always agree, but agreement is not visibility: told there are 4 matches, the screen may not show a single green block.

The shell bills per match too: the open tag costs 97 characters, the close tag 7, so one visible character pays 105 characters of markup, and an empty match likewise pays a 104-character shell in exchange for 0 visible characters. Pattern a against 10000 as: status 10000, marks 10000, highlight HTML 1050000 characters, while the source text is only 10000 characters — a 105-fold inflation, and the string is assembled synchronously inside one oninput event before being handed to innerHTML in one piece. How long the DOM actually needs to paint all of it was not measured in this batch — the probe stopped at the string layer and never opened a browser.

Three panels in one row: status line, mark content, group row count Pattern and text Status line mark inner chars Group panel (?:) vs abc 4 found 0 not shown \b vs a b c 6 found 0 not shown a* vs baab 4 found 3 empty not shown (a) vs 21 a's 21 found 21 spans only 20 rows shell: 97 + content + 7 1 char pays 105 10000 chars pay 1050000

Figure 2: the last row is exactly the ledger from the title — 21 grouped matches, and the panel stays at 20 rows.

The Group Panel Has More Than One Gate

  • Display gate: the block appears the moment any match carries a capture group. Pattern a|(b) against ab shows status 2 and the panel renders as usual, yet the first row contains no group cell at all.
  • Skipped-number gate: groups that did not participate are simply not rendered. (a)?(b) against ab b a b ab yields 4 rows, and rows 2 and 4 shrink until nothing is left but "Group2:" — Group1 vanishes into thin air.
  • Anonymity gate: (?<y>..) against abcd displays only "Group1: ab" and "Group1: cd"; no code on the page reads the named-group dictionary, so the name y effectively does not exist in the interface.
  • Index ledger: what follows index= is the absolute index in the source text, not a counter restarting from 0 every round. (a) against xa xa xa gives three rows reading #1 index=1, #2 index=4, #3 index=7.

The Old Ledger Hidden Away, and an Escaped-String Ledger

When the pattern is empty the code returns before construction (measured constructor calls: 0), both panels are hidden together, and not one line of hint is left. Syntax errors take the red-cross branch: with pattern ( the measured error string runs 61 characters / 77 bytes, the prefix occupies 9 code units (including one full-width colon) and the rest is the engine's own message; at the very same moment, the previous round's 210-character highlight and the "found 2 matches" status line are still lying in the hidden DOM, merely covered over. Clicking the clear button does not wipe them either: flags are reset to g rather than left empty, and the old 315-character status HTML is kept just the same.

The escaping rule only keeps a string ledger: escHtml touches just the three characters & < >, while double quotes go into innerHTML unchanged; an &amp; already written literally in the input gets eaten one more time, so one & produces one &amp;, and length 1 becomes length 5. The page itself is 4 functions and 9 ids, with no async and no debounce; replacement has no entry point here — there is neither a replacement input nor a replacement-result node, so if you want to change content after locating matches you have to move over to the batch find-and-replace page and do the work there.

Q: Why does typing uppercase flags produce no reaction at all?

The whitelist accepts only those 6 lowercase letters; the entire uppercase string is deleted before construction, and the empty remainder is topped up into g. The page gives no hint of this, so you can only infer it from the results.

Q: Typing gg reports a regex syntax error — is the page broken?

It is not. Duplicated letters are rejected by the constructor itself, and the page simply pastes the engine's message out as it arrived; even the variant with a space in the middle is washed into gg first and only then runs into the same error.

Q: The group panel has only 20 rows — did it match only 20 places?

No, the status line is the complete ledger. 20 is the cap of the slice step inside the panel code; with 40 matches it still lists only 20 rows, and the last row is always #20 index=19.

Q: Isn't a regex carrying g stateful? Why does that never show up here?

That is the territory covered by another hands-on test on this site. This page rebuilds a fresh RegExp on every keystroke (one constructor call per invocation), and the object keeps no state across inputs — do not mix the two facts into a single story.

📎 Related articles

3.7 becomes 3 and 1e3 becomes 1: this fraction calculator truncates at the input, then switches display rules at 14 characters 2026-11-14 The cut lands on the last dot only: archive.tar.gz yields gz, photo. and .gitignore share one cell, and the rebuilt column drops a dot 2026-11-14 Star Run Length Is Hardcoded: the Masking Page Cuts a Fixed 4, 3, 11 or 12 Stars, an 18-Unit String Is Taken by the Phone Tier, an Empty Box Falls Back to * 2026-11-13 The Lock Is One-Way and Percent Is Two Rulers: pct 50 Gives 619x430, and All Four PNG Quality Stops Return 6971 B 2026-11-13 A Month on This Page Is Hard-Coded as 30 Days: the Six Fixed Divisors, 13-Digit Millisecond Input, and slice(0,16) Truncation Measured Under UTC+8 and UTC-5 2026-11-13