Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve TypeScript types by removing [k: string]: any from LexicalNode #5223

Merged
merged 2 commits into from Dec 7, 2023

Conversation

etrepum
Copy link
Contributor

@etrepum etrepum commented Nov 10, 2023

This refactoring improves the ability for TypeScript to catch errors, particularly typos, because otherwise any unknown method would pass the type checker. I did find one bug in LinkNode (#5221) and some tests that were using textNode.length instead of textNode.__text.length.

The most awkward part was adding an explicit type to the constructors for the classes to implement Klass more precisely. I think that subclasses shouldn't typically need to do this since the superclass' type will generally be sufficient.

Another perhaps controversial addition was adding branded types for isNestedListNode and $isRootOrShadowRoot so that a more precise type could be refined with a predicate in a way that doesn't make TypeScript try and infer the wrong thing (e.g. if f(x): x is Node returns false then it will also infer !(x is Node) but that is not desired when we are looking for something more precise than the type alone.

Copy link

vercel bot commented Nov 10, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
lexical ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 7, 2023 7:30am
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Dec 7, 2023 7:30am

@etrepum
Copy link
Contributor Author

etrepum commented Nov 12, 2023

I think the failing collab tests are just flaky, based on trying them locally. The failing mac tests were a network error.

npm run test-e2e-collab-prod-chromium List.spec.mjs:1422

> @lexical/monorepo@0.12.2 test-e2e-collab-prod-chromium
> cross-env E2E_BROWSER=chromium E2E_PORT=4000 E2E_EDITOR_MODE=rich-text-with-collab playwright test --project="chromium" List.spec.mjs:1422


Running 1 test using 1 worker

  ✘  1 …/__tests__/e2e/List.spec.mjs:1422:3 › Nested List › remove list breaks when selection in empty nested list item 2 (4s)
  ✓  2 …e2e/List.spec.mjs:1422:3 › Nested List › remove list breaks when selection in empty nested list item 2 (retry #1) (2s)

@etrepum
Copy link
Contributor Author

etrepum commented Nov 12, 2023

Looks like another flaky collab test, I can reproduce the flakiness locally. Seems to fail more often than not with FIrefox but I haven't reproduced a failure with chromium.

❯ E2E_PORT=4000 npm run test-e2e-collab-firefox Toolbar.spec.mjs:37

> @lexical/monorepo@0.12.2 test-e2e-collab-firefox
> cross-env E2E_BROWSER=firefox E2E_EDITOR_MODE=rich-text-with-collab playwright test --project="firefox" Toolbar.spec.mjs:37


Running 1 test using 1 worker

  ✘  1 …refox] › packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs:37:3 › Toolbar › Insert image caption + table (5s)
  ✓  2 …ckages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs:37:3 › Toolbar › Insert image caption + table (retry #1) (3s)


  1) [firefox] › packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs:37:3 › Toolbar › Insert image caption + table 

    Error: expect(received).toEqual(expected) // deep equality

    - Expected  - 0
    + Received  + 1

    @@ -13,10 +13,11 @@
              spellcheck="true"
              data-lexical-editor="true">
              <p dir="ltr">
                <span data-lexical-text="true">Yellow flower in tilt shift lens</span>
              </p>
    +         <p><br /></p>
            </div>
          </div>
        </span>
        <br />
      </p>

       at packages/lexical-playground/__tests__/utils/index.mjs:159

      157 |     ignoreInlineStyles,
      158 |   });
    > 159 |   expect(actual).toEqual(expected);
          |                  ^
      160 | }
      161 |
      162 | export async function assertHTML(

        at assertHTMLOnPageOrFrame (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:159:18)
        at retryAsync (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:204:7)
        at withRetry (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:169:37)
        at async Promise.all (index 0)
        at assertHTML (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:170:5)
        at file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs:47:5


  1 flaky
    [firefox] › packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs:37:3 › Toolbar › Insert image caption + table 

@etrepum
Copy link
Contributor Author

etrepum commented Nov 13, 2023

More flaky tests in isCollab, here's a few of them

❯ E2E_PORT=4000 npm run test-e2e-collab-chromium packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs:121

> @lexical/monorepo@0.12.2 test-e2e-collab-chromium
> cross-env E2E_BROWSER=chromium E2E_EDITOR_MODE=rich-text-with-collab playwright test --project="chromium" packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs:121


Running 1 test using 1 worker

  ✘  1 …cal-playground/__tests__/e2e/TextEntry.spec.mjs:121:3 › TextEntry › Can insert a paragraph between two text nodes (4s)
  ✓  2 …und/__tests__/e2e/TextEntry.spec.mjs:121:3 › TextEntry › Can insert a paragraph between two text nodes (retry #1) (2s)


  1) [chromium] › packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs:121:3 › TextEntry › Can insert a paragraph between two text nodes 

    Error: expect(received).toEqual(expected) // deep equality

    - Expected  - 2
    + Received  + 5

    - <p dir="ltr"><span data-lexical-text="true">Hello</span></p>
    + <p dir="ltr">
    +   <span data-lexical-text="true">Hello</span>
    +   <strong data-lexical-text="true">w</strong>
    + </p>
    - <p dir="ltr"><strong data-lexical-text="true">world</strong></p>
    + <p dir="ltr"><strong data-lexical-text="true">orld</strong></p>

       at packages/lexical-playground/__tests__/utils/index.mjs:159

      157 |     ignoreInlineStyles,
      158 |   });
    > 159 |   expect(actual).toEqual(expected);
          |                  ^
      160 | }
      161 |
      162 | export async function assertHTML(

        at assertHTMLOnPageOrFrame (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:159:18)
        at retryAsync (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:204:7)
        at withRetry (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:169:37)
        at async Promise.all (index 0)
        at assertHTML (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:170:5)
        at file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs:133:5


  1 flaky
    [chromium] › packages/lexical-playground/__tests__/e2e/TextEntry.spec.mjs:121:3 › TextEntry › Can insert a paragraph between two text nodes 
> cross-env E2E_BROWSER=chromium E2E_PORT=4000 E2E_EDITOR_MODE=rich-text-with-collab playwright test --project="chromium" Links.spec.mjs:425


Running 1 test using 1 worker

  ✘  1 …5:3 › Links › Can create a link with some text after, insert paragraph, then backspace, it should merge correctly (4s)
  ✓  2 … › Can create a link with some text after, insert paragraph, then backspace, it should merge correctly (retry #1) (2s)


  1) [chromium] › packages/lexical-playground/__tests__/e2e/Links.spec.mjs:425:3 › Links › Can create a link with some text after, insert paragraph, then backspace, it should merge correctly 

    Error: expect(received).toEqual(expected) // deep equality

    - Expected  - 2
    + Received  + 2

    @@ -3,20 +3,20 @@
        <a
          class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
          dir="ltr"
          href="https://"
          rel="noreferrer">
    -     <span data-lexical-text="true">ab</span>
    +     <span data-lexical-text="true">a</span>
        </a>
      </p>
      <p
        class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
        dir="ltr">
        <a
          class="PlaygroundEditorTheme__link PlaygroundEditorTheme__ltr"
          dir="ltr"
          href="https://"
          rel="noreferrer">
    -     <span data-lexical-text="true">c</span>
    +     <span data-lexical-text="true">bc</span>
        </a>
        <span data-lexical-text="true">def</span>
      </p>

       at packages/lexical-playground/__tests__/utils/index.mjs:159

      157 |     ignoreInlineStyles,
      158 |   });
    > 159 |   expect(actual).toEqual(expected);
          |                  ^
      160 | }
      161 |
      162 | export async function assertHTML(

        at assertHTMLOnPageOrFrame (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:159:18)
        at retryAsync (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:204:7)
        at withRetry (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:169:37)
        at async Promise.all (index 0)
        at assertHTML (file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/utils/index.mjs:170:5)
        at file:///Users/bob/src/lexical/packages/lexical-playground/__tests__/e2e/Links.spec.mjs:459:5


  1 flaky
    [chromium] › packages/lexical-playground/__tests__/e2e/Links.spec.mjs:425:3 › Links › Can create a link with some text after, insert paragraph, then backspace, it should merge correctly 

@acywatson
Copy link
Contributor

Thanks for this - would be great to get it merged.

@etrepum
Copy link
Contributor Author

etrepum commented Nov 23, 2023

Happy to do whatever I can to speed that along, short of rewriting the collaboration support to be consistent in tests 😅

@acywatson
Copy link
Contributor

Happy to do whatever I can to speed that along, short of rewriting the collaboration support to be consistent in tests 😅

yea don't worry about those tests. I skimmed this and I don't see an issue, but since it's a larger change let me just make sure the rest of the core team has a chance to object. I'll ping internally after the holidays.

@GermanJablo
Copy link
Contributor

This is one of the things that was necessary in this PR: #3931

However, it seems better that this PR addresses it in isolation. Very cool!

@etrepum
Copy link
Contributor Author

etrepum commented Dec 6, 2023

Let me know if there's anything I can do to move this along. I'm willing to do the work of rebasing and resolving conflicts once or twice if that gets it into a release.

@acywatson
Copy link
Contributor

Let me know if there's anything I can do to move this along. I'm willing to do the work of rebasing and resolving conflicts once or twice if that gets it into a release.

I actually just pinged internally about this and I don't think there are any objections here. If you don't mind rebasing, I will approve and merge. Thanks!

This refactoring improves the ability for TypeScript to catch errors,
particularly typos, because otherwise any unknown method would pass the
type checker. I did find one bug in LinkNode (facebook#5221) and some tests that
were using textNode.length instead of textNode.__text.length.

The most awkward part was adding an explicit type to the constructors for
the classes to implement Klass<T> more precisely. I think that subclasses
shouldn't typically need to do this since the superclass' type will
generally be sufficient.

Another perhaps controversial addition was adding branded types for
isNestedListNode and $isRootOrShadowRoot so that a more precise type
could be refined with a predicate in a way that doesn't make
TypeScript try and infer the wrong thing (e.g. if `f(x): x is Node`
returns false then it will also infer `!(x is Node)` but that is
not desired when we are looking for something more precise than the
type alone.
@etrepum
Copy link
Contributor Author

etrepum commented Dec 6, 2023

Should be good to go now, if there's any feedback or unexpected test failures I'm happy to address them

@acywatson
Copy link
Contributor

Should be good to go now, if there's any feedback or unexpected test failures I'm happy to address them

Thanks!

This one is actually a little suspicious, because it's failing consistently across collab suites:

[1] 1 failed
[1] [chromium] › packages/lexical-playground/tests/e2e/CopyAndPaste/html/ImageHTMLCopyAndPaste.spec.mjs:64:3 › HTML Image CopyAndPaste › Copy + paste + undo multiple image

Typically with flakiness on collab we'll see most suites pass, or a couple fail with different tests. Usually the same test failing across multiple suites (and runs, since I re-ran) is indicative of a problem somewhere.

Not sure if you have any ideas of what it could be off the top of your head.

@etrepum
Copy link
Contributor Author

etrepum commented Dec 7, 2023

I don't think that is a regression in this PR, I can reproduce a consistent failure of that test locally from main and several older tags. I couldn't find any tag where this test passed. I did not do an exhaustive search, just a few in the v0.12.x and v0.11.x series by hand.

@etrepum
Copy link
Contributor Author

etrepum commented Dec 7, 2023

Scratch that, I was running the tests wrong (forgot to start the collab server) and can repro a success now. Will look closer!

@etrepum
Copy link
Contributor Author

etrepum commented Dec 7, 2023

@acywatson so it turns out that this test is timing dependent, doesn't have its own sleep in the right place, but it worked because it was dependent on a distant bug which caused an unintentional unconditional sleep. I had fixed that bug because the type was wrong (parentheses in the wrong place for an await expression). I restored the unconditional sleep and now it works again. See 2c5b210

@acywatson
Copy link
Contributor

acywatson commented Dec 7, 2023

@acywatson so it turns out that this test is timing dependent, doesn't have its own sleep in the right place, but it worked because it was dependent on a distant bug which caused an unintentional unconditional sleep. I had fixed that bug because the type was wrong (parentheses in the wrong place for an await expression). I restored the unconditional sleep and now it works again. See 2c5b210

Beautiful! Thank you. We really need to sit down and have a look at the test setup holistically.

@acywatson acywatson merged commit 1a3c911 into facebook:main Dec 7, 2023
45 checks passed
@alicercedigital
Copy link

alicercedigital commented Feb 15, 2024

This change causes the append method to not be recognized as a type.

editor.registerCommand(
    ADD_TEXT_WITH_CHUNKS,
    (payload) => {
     const root = $getRoot();
     root.getFirstChild()?.append($createTextNode(payload));
     return false;
    },
    priority,
   ),
);

Does this mean we are doing this the wrong way?

@etrepum
Copy link
Contributor Author

etrepum commented Feb 15, 2024

This change causes the append method to not be recognized as a type.


editor.registerCommand(

    ADD_TEXT_WITH_CHUNKS,

    (payload) => {

     const root = $getRoot();

     root.getFirstChild()?.append($createTextNode(payload));

     return false;

    },

    priority,

   ),

);

Does this mean we are doing this the wrong way?

Yeah, there's no type level guarantee that getFirstChild is an ElementNode

@etrepum
Copy link
Contributor Author

etrepum commented Feb 15, 2024

@alicercedigital There are a couple ways to do this that TypeScript would not complain about. The reason it worked before is because node.anyTypoYouCouldImagine would be typed as any, so it would not stop you from calling any method at all, even ones that did not exist, so long as it didn't have an existing type that conflicted with what you were doing. According to the type checker, here you are trying to call append() on a LexicalNode, which should be an error because it is only implemented by ElementNode.

  1. For whatever reason, there's an unsound generic cast available on getFirstChild (which predates this PR), so you can write:
root.getFirstChild<ElementNode>()?.append($createTextNode(payload));

This is unsound in general, and exactly what you were doing before at runtime, but RootNode's children should only be ElementNode so it's not an issue in practice unless there are other bugs.

  1. If you wanted to use the same logical code, but with a runtime type check, you would need to make this two lines:
const parent = root.getFirstChild();
if ($isElementNode(parent)) {
  parent.append($createTextNode(payload));
}
  1. You could also use LexicalNode's getTopLevelElement, which is basically the same as 2 but does a little more work since it looks up the root again before the type check:
root.getFirstChild()?.getTopLevelElement()?.append($createTextNode(payload));

@alicercedigital
Copy link

alicercedigital commented Feb 15, 2024

@alicercedigital There are a couple ways to do this that TypeScript would not complain about. The reason it worked before is because node.anyTypoYouCouldImagine would be typed as any, so it would not stop you from calling any method at all, even ones that did not exist, so long as it didn't have an existing type that conflicted with what you were doing. According to the type checker, here you are trying to call append() on a LexicalNode, which should be an error because it is only implemented by ElementNode. [...]

Thank you for the careful and explanatory response!

@etrepum etrepum deleted the improve-LexicalNode-typescript branch May 14, 2024 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants