Skip to content

Bug: [lexical-y] Two collaborators bolding overlapping ranges at the same time leave part of the text plain #9626

Open

Description

@zurfyx

Lexical version: main at 4421e5b (@lexical/y), and the 20261030 nightly (0.53.1-nightly.20261030.0, e28f4dd).

When two collaborators apply the same format to overlapping ranges of a line at the same time, part of the text ends up without the format, on every client. Both users applied it, and nobody removed it.

Relation to the documented limits. The Limits section of docs/collaboration/y.md ("Other races") says: "Bold ‖ bold on adjacent ranges of one line can leave part unformatted." The ranges here overlap (one inside the other, or sharing words), which that sentence does not cover, and the decision register (packages/lexical-y/DECISIONS.md) has no row for it. It is not rare: in a randomized run with 2 to 3 clients each applying 1 to 2 random formats or comments to whole-word ranges of one line, 108 of 1,000 sessions lost a format where two clients' ranges of the same format overlapped (1 of 1,000 where they were only adjacent). Comments were never lost (their ids are separate keys).

Mechanism. The same edits on a plain @y/y text (format cleanup off) lose the bold the same way, so this comes from how Yjs stores formats as begin/end items (the class of yjs/yjs#382). @lexical/y writes its own format items, so a binding-side rule may be able to avoid it.

Links lose part of their range the same way. A links t2z t3z t4z t5z while B links t3z (to another URL, or the same one): every client shows t1z t2z t3z t4z t5z t6z: t4z t5z lose A's link. The same for A t2z..t5z ‖ B t3z..t4z, and A t2z..t3z ‖ B t3z..t5z, in both client id orders (the second test below). A link a collaborator applies inside your link at the same time should only change the text it covers.

Removals too. The same happens when two collaborators remove a format, or a comment, from overlapping ranges at once: on t1z t2z t3z t4z t5z, all bold, A removes bold from t2z while B removes it from t2z t3z t4z: every client shows t2z plain and t3z t4z bold again (B's removal is lost past A's word); the same with a comment ($removeMarkID('c1', …): resolving a comment on part of its text while a collaborator resolves it on more leaves it on the rest), and with A t3z ‖ B t2z..t4z or A t2z..t3z ‖ B t3z..t4z, in both id orders. A plain @y/y text with the same format(…, {bold: null}) calls gives the same result. Repro cells: unbold by splitting the selection (selection.extract(), then setFormat(format & ~1) on each text node) or $removeMarkID, then sync.

Styles and text NodeState too. The nested shape (A t2z..t5z ‖ B t3z..t4z) with $patchStyleText(selection, {color: 'red'}) on both clients, or with both setting the same value of a text NodeState key, loses the style or the state on part of A's range the same way (4/4 cells, both id orders).

Steps To Reproduce

  1. Two clients bind one document holding one paragraph t1z t2z t3z t4z t5z t6z.
  2. While offline from each other: client A bolds t2z t3z t4z t5z; client B bolds t3z t4z.
  3. The clients sync.

Link to code example: the test below. Save it as packages/lexical-y/src/__tests__/unit/Repro.test.ts and run pnpm exec vitest run --project unit packages/lexical-y/src/__tests__/unit/Repro.test.ts. On main every Lexical case (formats and links) fails in both client id orders, and the plain Yjs control passes (it shows the same loss).

Repro test (main at 4421e5b and the 20261030 nightly: 8 format cases and 8 link cases fail)
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

import {$toggleLink, LinkNode} from '@lexical/link';
import * as Y from '@y/y';
import {$createParagraphNode, $createTextNode, $getRoot, $setSelection} from 'lexical';
import {describe, expect, test} from 'vitest';

import {$selectIn, expectConverged, setup} from '../utils/harness';

// "t1z t2z t3z t4z t5z t6z": t2z 4..7, t3z 8..11, t4z 12..15, t5z 16..19
const CASES: [string, [number, number, string][], [number, number, string][]][] = [
  ['A bolds t2z-t5z, B bolds t3z-t4z', [[4, 19, 'bold']], [[8, 15, 'bold']]],
  ['A bolds t2z-t5z, B bolds t2z-t4z', [[4, 19, 'bold']], [[4, 15, 'bold']]],
  ['A bolds t2z-t3z, B bolds t3z-t5z', [[4, 11, 'bold']], [[8, 19, 'bold']]],
  ['A bolds t2z-t5z, B bolds t2z-t4z and italicizes t3z-t5z', [[4, 19, 'bold']], [[4, 15, 'bold'], [8, 19, 'italic']]],
];

describe('two collaborators bold overlapping ranges at the same time', () => {
  for (const ids of [
    [1, 2],
    [2, 1],
  ]) {
    for (const [name, aOps, bOps] of CASES) {
      test(`${name} (ids ${ids})`, async () => {
        const t = await setup(() => {
          $getRoot().append($createParagraphNode().append($createTextNode('t1z t2z t3z t4z t5z t6z')));
        }, {ids});
        t.offline();
        for (const [peer, ops] of [
          [t.a, aOps],
          [t.b, bOps],
        ] as const) {
          for (const [start, end, format] of ops) {
            peer.update(() => {
              $selectIn([0], start, end).formatText(format as 'bold');
              $setSelection(null);
            });
          }
        }
        await t.sync();
        await expectConverged(t.peers);
        // Every character a bold covered is bold.
        const covered = new Set<number>();
        for (const [start, end, format] of [...aOps, ...bOps]) {
          if (format === 'bold') {
            for (let i = start; i < end; i++) {
              covered.add(i);
            }
          }
        }
        const notBold = t.a.read(() => {
          const out: number[] = [];
          let offset = 0;
          for (const node of $getRoot().getAllTextNodes()) {
            for (let i = 0; i < node.getTextContentSize(); i++) {
              if (covered.has(offset + i) && !node.hasFormat('bold')) {
                out.push(offset + i);
              }
            }
            offset += node.getTextContentSize();
          }
          return out;
        });
        expect({line: t.a.describe(), notBold}).toEqual({line: t.a.describe(), notBold: []});
      });
    }
  }

  test('control: the same formats on a plain Yjs text lose the bold too', () => {
    for (const [ida, idb] of [
      [1, 2],
      [2, 1],
    ]) {
      const make = (id: number) => {
        const doc = new Y.Doc();
        doc.clientID = id;
        (doc as unknown as {cleanupFormatting: boolean}).cleanupFormatting = false;
        return doc;
      };
      const a = make(ida);
      const b = make(idb);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const text = (doc: Y.Doc) => doc.get('t') as any;
      text(a).insert(0, 't1z t2z t3z t4z t5z t6z');
      Y.applyUpdate(b, Y.encodeStateAsUpdate(a), 'remote');
      text(a).format(4, 15, {bold: true});
      text(b).format(4, 11, {bold: true});
      text(b).format(8, 11, {italic: true});
      Y.applyUpdate(b, Y.encodeStateAsUpdate(a), 'remote');
      Y.applyUpdate(a, Y.encodeStateAsUpdate(b), 'remote');
      // " t5z" keeps only italic: A's bold is lost there.
      expect(JSON.stringify(text(a).toDelta())).toContain('" t5z","format":{"italic":true}');
    }
  });
});

describe('two collaborators link overlapping ranges at the same time', () => {
  for (const ids of [
    [1, 2],
    [2, 1],
  ]) {
    for (const [name, a, b, url] of [
      ['A links t2z-t5z, B links t3z to another URL', [4, 19], [8, 11], 'b'],
      ['A links t2z-t5z, B links t3z to the same URL', [4, 19], [8, 11], 'a'],
      ['A links t2z-t5z, B links t3z-t4z to another URL', [4, 19], [8, 15], 'b'],
      ['A links t2z-t3z, B links t3z-t5z to another URL', [4, 11], [8, 19], 'b'],
    ] as [string, [number, number], [number, number], string][]) {
      test(`${name} (ids ${ids})`, async () => {
        const t = await setup(() => {
          $getRoot().append($createParagraphNode().append($createTextNode('t1z t2z t3z t4z t5z t6z')));
        }, {ids});
        t.offline();
        t.a.update(() => {
          $selectIn([0], a[0], a[1]);
          $toggleLink('https://x.test/a');
          $setSelection(null);
        });
        t.b.update(() => {
          $selectIn([0], b[0], b[1]);
          $toggleLink('https://x.test/' + url);
          $setSelection(null);
        });
        await t.sync();
        await expectConverged(t.peers);
        // Every character either client linked is in a link.
        const notLinked = t.a.read(() => {
          const out: number[] = [];
          let offset = 0;
          for (const node of $getRoot().getAllTextNodes()) {
            for (let i = 0; i < node.getTextContentSize(); i++) {
              const at = offset + i;
              const covered = (at >= a[0] && at < a[1]) || (at >= b[0] && at < b[1]);
              if (covered && !(node.getParent() instanceof LinkNode)) {
                out.push(at);
              }
            }
            offset += node.getTextContentSize();
          }
          return out;
        });
        expect({line: t.a.describe(), notLinked}).toEqual({line: t.a.describe(), notLinked: []});
      });
    }
  }
});

The current behavior

A bolds t2z t3z t4z t5z while B bolds t3z t4z. After the sync, every client shows t1z t2z t3z t4z t5z t6z: t4z and t5z are plain, although A bolded both and B bolded t4z. The other three shapes lose the bold on part of the range in the same way, in both client id orders.

The expected behavior

Every character either client bolded is bold (t2z t3z t4z t5z bold), as when the two edits are made one after the other. If this cannot be fixed, the Limits section and the decision register should say that overlapping ranges, not only adjacent ones, can lose a format.

Impact of fix

Two people formatting the same passage at the same time is common (both fixing a heading, an agent and a person bolding the same key terms). The lost format converges, so nobody is told; one of the users sees their formatting silently undone.

Activity

  1. zurfyx commented on Oct 31, 2026

    @zurfyx
    MemberAuthor

    Two more shapes of this race, with a link. Both lose formatting only, never text.

    1. A range crossing a link's end. A links t5z t6z, then bolds t4z..t7z, while B bolds t3z..t6z at the same time. t7z should end bold: A bolded it and nobody else touched it.

    ids [1, 2] ids [2, 1]
    No link (the shape above) plain on main and on #9601 plain on main and on #9601
    With the link plain on main and on #9601 bold on main, plain on #9601

    2. A link's first word, with a collaborator one update behind.

    Why the link changes it. On main, a format over a link also writes the link's value again, which opens the format inside the link. That is what kept t7z bold in those cells. #9601 stops storing a link's textFormat while it holds text (the fix for #9597), so a link now behaves like a mark there: with a comment instead of the link, main loses t7z's bold the same way. #9601 pins both shapes as expected failures naming this issue (BoldAcrossLinkEnd.test.ts, BoldIntoLinkBehind.test.ts).

    Repro for shape 1 (save as packages/lexical-y/src/__tests__/unit/Repro.test.ts)
    /**
     * Copyright (c) Meta Platforms, Inc. and affiliates.
     *
     * This source code is licensed under the MIT license found in the
     * LICENSE file in the root directory of this source tree.
     *
     */
    // @vitest-environment node
    
    // Two collaborators bold overlapping ranges at the same time: A bolds
    // t4z..t7z while B bolds t3z..t6z. t7z should end bold: A bolded it and
    // nobody else touched it. Two items of one format key at one spot resolve by
    // client id, and part of A's range ends plain (#9626), without a link for
    // both client id orders. With A first linking t5z t6z (A's bold then crosses
    // the link's end), main kept t7z bold for client ids 2,1 only because A's
    // bold also wrote the link's value again; a link's text fields are no longer
    // stored (#9597), so the link behaves like a mark there and t7z ends plain
    // for both orders: expected failures naming #9626.
    
    import {$toggleLink} from '@lexical/link';
    import {
      $createParagraphNode,
      $createTextNode,
      $getRoot,
      $setSelection,
      type TextNode,
    } from 'lexical';
    import {expect, test} from 'vitest';
    
    import {$selectIn, expectConverged, setup} from '../utils/harness';
    
    const LINE = 't1z t2z t3z t4z t5z t6z t7z t8z';
    const at = (word: string) => LINE.indexOf(word);
    
    for (const ids of [
      [1, 2],
      [2, 1],
    ]) {
      for (const [name, aLinksFirst, bBolds] of [
        ['A links t5z t6z, then bolds t4z..t7z || B bolds t3z..t6z', true, true],
        ['A bolds t4z..t7z || B bolds t3z..t6z (no link)', false, true],
        ['control: A links t5z t6z, then bolds t4z..t7z', true, false],
      ] as [string, boolean, boolean][]) {
        test(
          `${name}, ids ${ids}: t7z keeps A's bold`,
          async () => {
            const t = await setup(
              () => {
                $getRoot().append(
                  $createParagraphNode().append($createTextNode(LINE)),
                );
              },
              {ids},
            );
            try {
              t.offline();
              if (aLinksFirst) {
                t.a.update(() => {
                  $selectIn([0], at('t5z'), at('t6z') + 3);
                  $toggleLink('https://x.test/12');
                  $setSelection(null);
                });
              }
              t.a.update(() => {
                $selectIn([0], at('t4z'), at('t7z') + 3).formatText('bold');
                $setSelection(null);
              });
              if (bBolds) {
                t.b.update(() => {
                  $selectIn([0], at('t3z'), at('t6z') + 3).formatText('bold');
                  $setSelection(null);
                });
              }
              await t.sync();
              await expectConverged(t.peers);
              expect(
                t.a.read(() =>
                  (
                    $getRoot()
                      .getAllTextNodes()
                      .find(n => n.getTextContent().includes('t7z')) as TextNode
                  ).hasFormat('bold'),
                ),
              ).toBe(true);
            } finally {
              for (const p of t.peers) {
                p.stop();
              }
            }
          },
        );
      }
    }
  2. zurfyx commented on Oct 31, 2026

    @zurfyx
    MemberAuthor

    Another shape of this class, with styles and two different values: the overlap leaves part of the text with a value nobody wrote.

    A line azy aamma jot with amma green. While offline from each other, client C colors zy a green (so its green joins the existing green run) and client B colors aamm blue; then they sync. Every client, and a fresh readYState, shows:

    a / zy green / a blue / amm no color / a green / jot

    amm was green before, only B wrote to it (blue), and it ends uncolored: neither the old value nor B's. Same result in both client id orders, on main 551fe4a, on 4421e5b and on 0823b74. A build with a removal that keeps other changes' items and overrides them (a measurement build of the alternative design, not a PR) keeps aamm blue.

    Found by a three-client randomized test that recolors a few characters in turn next to formatted text that is deleted and retyped.

    Repro test (fails on main in both id orders)
    /**
     * Copyright (c) Meta Platforms, Inc. and affiliates.
     *
     * This source code is licensed under the MIT license found in the
     * LICENSE file in the root directory of this source tree.
     *
     */
    
    // @vitest-environment node
    
    // "azy aamma jot" with "amma" green. Concurrently C colors "zy a" green and
    // B colors " aamm" blue; then they sync. "amm" ends with no color at all.
    
    import { buildEditorFromExtensions } from '@lexical/extension';
    import { RichTextExtension } from '@lexical/rich-text';
    import { $patchStyleText } from '@lexical/selection';
    import { createYDoc, YCollabExtension } from '@lexical/y';
    import { readYState, seedYState } from '@lexical/y/headless';
    import * as Y from '@y/y';
    import {
      $createParagraphNode,
      $createRangeSelection,
      $createTextNode,
      $getRoot,
      $isElementNode,
      $isTextNode,
      $setSelection,
      configExtension,
      defineExtension,
      type LexicalEditor,
    } from 'lexical';
    import { describe, expect, it } from 'vitest';
    
    const CONFIG = { extensions: [RichTextExtension], schemaVersion: 1 };
    
    function $chars(): string {
      const p = $getRoot().getFirstChild();
      if (!$isElementNode(p)) return '';
      return p
        .getChildren()
        .map((t) => ($isTextNode(t) ? `${t.getTextContent()}{${t.getStyle()}}` : '?'))
        .join('|');
    }
    
    function clients(ids: number[]) {
      const seeded = seedYState(CONFIG, () => {
        $getRoot().clear().append($createParagraphNode().append($createTextNode('azy a'), $createTextNode('amma').setStyle('color: #0a0;'), $createTextNode(' jot')));
      });
      return ids.map((id) => {
        const doc = createYDoc();
        doc.clientID = id;
        Y.applyUpdate(doc, seeded);
        const editor = buildEditorFromExtensions(
          defineExtension({ name: `c${id}`, dependencies: [RichTextExtension, configExtension(YCollabExtension, { doc, schemaVersion: 1, synced: true })] }),
        );
        return { doc, editor };
      });
    }
    
    /** Patch `color` over line offsets [a, b) (across text nodes). */
    function patch(editor: LexicalEditor, a: number, b: number, color: string) {
      editor.update(
        () => {
          const p = $getRoot().getFirstChild();
          if (!$isElementNode(p)) throw new Error('no line');
          const ts = p.getChildren().filter($isTextNode);
          const at = (off: number) => {
            let left = off;
            for (const t of ts) {
              if (left <= t.getTextContentSize()) return { t, o: left };
              left -= t.getTextContentSize();
            }
            throw new Error('offset');
          };
          const s = $createRangeSelection();
          const p0 = at(a);
          const p1 = at(b);
          s.anchor.set(p0.t.getKey(), p0.o, 'text');
          s.focus.set(p1.t.getKey(), p1.o, 'text');
          $setSelection(s);
          $patchStyleText(s, { color });
          $setSelection(null);
        },
        { discrete: true },
      );
    }
    
    describe('concurrent color patches over overlapping ranges', () => {
      for (const ids of [
        [1, 2],
        [2, 1],
      ]) {
        it(`B's blue stays on the characters only B colored (ids ${ids})`, () => {
          const [B, C] = clients(ids);
          patch(C!.editor, 1, 5, '#0a0'); // "zy a" green
          patch(B!.editor, 3, 8, '#00c'); // " aamm" blue
          Y.applyUpdate(C!.doc, Y.encodeStateAsUpdate(B!.doc, Y.encodeStateVector(C!.doc)));
          Y.applyUpdate(B!.doc, Y.encodeStateAsUpdate(C!.doc, Y.encodeStateVector(B!.doc)));
          const b = B!.editor.getEditorState().read($chars);
          const c = C!.editor.getEditorState().read($chars);
          const fresh = readYState(Y.encodeStateAsUpdate(B!.doc), CONFIG, $chars);
          console.log(JSON.stringify({ ids, b }));
          expect(c).toBe(b);
          expect(fresh).toBe(b);
          // characters 5..8 = "amm": only B colored them
          const flat: string[] = [];
          for (const part of b.split('|')) {
            const m = /^(.*)\{(.*)\}$/.exec(part)!;
            for (const ch of m[1]!) flat.push(m[2]!);
          }
          expect(flat.slice(5, 8)).toEqual(['color: #00c;', 'color: #00c;', 'color: #00c;']);
        });
      }
    });
  3. zurfyx commented on Oct 31, 2026

    @zurfyx
    MemberAuthor

    A related shape where a character ends with a color nobody wrote to it in that round: an older value comes back.

    One line abcdefghijklmnopqrstuvwx, two clients.

    1. At the same time: A colors mn green, B colors n red. Sync.
    2. At the same time: A colors mn gray, B colors m red. Sync.

    Only A wrote to n in step 2 (gray). Every client ends with m red and n green: A's value from step 1, which step 1 had already replaced on n. Both client id orders, on main c7f6139 and on 0823b74. The same calls on a plain @y/y text (format(…, {style}), with and without createYDoc) end with mn red: n takes B's red, which B wrote only to m, so Yjs's begin/end items give a value nobody wrote there as well (the yjs/yjs#382 class this issue already names). A measurement build where a removal keeps other changes' items and overrides them (the alternative design, not a PR) passes this case.

    Rate: in a randomized test (3 clients, 1-3 color patches each per round over random ranges of one line, 3 rounds, one client also formats-deletes-retypes 1-3 characters; each character checked against the colors written to it in that round, or its previous color), 278 of 1,000 sessions end with at least one character in a color nobody wrote to it that round, identical on main c7f6139, 551fe4a and 0823b74; the alternative-design build: 62 of 1,000. The test above is that rate's shortest case (shrunk from 15 ops to 4).

    Repro test (fails on main in both id orders)
    /**
     * Copyright (c) Meta Platforms, Inc. and affiliates.
     *
     * This source code is licensed under the MIT license found in the
     * LICENSE file in the root directory of this source tree.
     *
     */
    
    // @vitest-environment node
    
    // One line "abcdefghijklmnopqrstuvwx". Round 1, at the same time: A colors
    // "mn" green, B colors "n" red; sync. Round 2, at the same time: A colors
    // "mn" gray, B colors "m" red; sync. Only A wrote to "n" in round 2 (gray),
    // yet "n" ends green: A's value from round 1, which round 1 already replaced.
    
    import {buildEditorFromExtensions} from '@lexical/extension';
    import {RichTextExtension} from '@lexical/rich-text';
    import {$patchStyleText} from '@lexical/selection';
    import {createYDoc, YCollabExtension} from '@lexical/y';
    import {seedYState} from '@lexical/y/headless';
    import * as Y from '@y/y';
    import {
      $createParagraphNode,
      $createRangeSelection,
      $createTextNode,
      $getRoot,
      $isElementNode,
      $isTextNode,
      $setSelection,
      configExtension,
      defineExtension,
      type LexicalEditor,
    } from 'lexical';
    import {expect, test} from 'vitest';
    
    const CONFIG = {extensions: [RichTextExtension], schemaVersion: 1};
    
    /** Each character with its color. */
    function $colors(): string {
      const p = $getRoot().getFirstChild();
      if (!$isElementNode(p)) {
        return '';
      }
      return p
        .getChildren()
        .filter($isTextNode)
        .map(t => {
          const c = /color: ([^;]+);/.exec(t.getStyle())?.[1];
          return c ? `${t.getTextContent()}{${c}}` : t.getTextContent();
        })
        .join('|');
    }
    
    function color(editor: LexicalEditor, letters: string, value: string) {
      editor.update(
        () => {
          const p = $getRoot().getFirstChild();
          if (!$isElementNode(p)) {
            throw new Error('no line');
          }
          const text = p.getTextContent();
          const a = text.indexOf(letters);
          const ts = p.getChildren().filter($isTextNode);
          const at = (off: number) => {
            let left = off;
            for (const t of ts) {
              if (left <= t.getTextContentSize()) {
                return {o: left, t};
              }
              left -= t.getTextContentSize();
            }
            throw new Error('offset');
          };
          const s = $createRangeSelection();
          const p0 = at(a);
          const p1 = at(a + letters.length);
          s.anchor.set(p0.t.getKey(), p0.o, 'text');
          s.focus.set(p1.t.getKey(), p1.o, 'text');
          $setSelection(s);
          $patchStyleText(s, {color: value});
          $setSelection(null);
        },
        {discrete: true},
      );
    }
    
    for (const ids of [
      [821, 4832],
      [4832, 821],
    ]) {
      test(`a character takes back a color its round replaced (ids ${ids})`, () => {
        const seeded = seedYState(CONFIG, () => {
          $getRoot()
            .clear()
            .append(
              $createParagraphNode().append(
                $createTextNode('abcdefghijklmnopqrstuvwx'),
              ),
            );
        });
        const [A, B] = ids.map(id => {
          const doc = createYDoc();
          doc.clientID = id;
          Y.applyUpdate(doc, seeded);
          const editor = buildEditorFromExtensions(
            defineExtension({
              dependencies: [
                RichTextExtension,
                configExtension(YCollabExtension, {
                  doc,
                  schemaVersion: 1,
                  synced: true,
                }),
              ],
              name: `c${id}`,
            }),
          );
          return {doc, editor};
        });
        const sync = () => {
          Y.applyUpdate(B.doc, Y.encodeStateAsUpdate(A.doc, Y.encodeStateVector(B.doc)));
          Y.applyUpdate(A.doc, Y.encodeStateAsUpdate(B.doc, Y.encodeStateVector(A.doc)));
        };
        color(A.editor, 'mn', 'green');
        color(B.editor, 'n', 'red');
        sync();
        const afterRound1 = A.editor.getEditorState().read($colors);
        color(A.editor, 'mn', 'gray');
        color(B.editor, 'm', 'red');
        sync();
        const a = A.editor.getEditorState().read($colors);
        expect(B.editor.getEditorState().read($colors)).toBe(a);
        // "n": gray (A's round-2 write, the only one to it) or what it showed after round 1.
        const nColor = /(?:^|\|)[^|]*n[^|]*?\{(\w+)\}/.exec(a)?.[1] ?? 'none';
        const nBefore = /(?:^|\|)[^|]*n[^|]*?\{(\w+)\}/.exec(afterRound1)?.[1] ?? 'none';
        expect([`gray`, nBefore]).toContain(nColor);
      });
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions