Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
by facebookTypeScript
Last 12 weeks · 264 commits
5 of 6 standards met
Description Current behavior: in is what the playground passes to as . It returns , and is unanchored, so it answers "does this string contain something URL shaped" rather than "is this string a URL". gates its listener on that predicate (, ), so pasting over a selection makes the whole sentence the href. The library side is doing exactly what it documents; the predicate it is handed is the thing that is wrong. That is also what the issue asks for: wrapping should happen only when the whole clipboard payload is a single link. This change: trim the input, reject it if any whitespace survives the trim, then test it as before. A URL cannot contain raw whitespace, so that single check is what turns "contains a URL" into "is a URL". The trim is deliberate rather than incidental: copying a URL off its own line carries a trailing newline along with it, and that still has to link. keeps its existing exemption, now compared after trimming, so a padded validates where it did not before. Worth being explicit about the scope, since is not only the paste gate: consults it too, so setting a link from the link editor is tightened the same way and a value with a space in it is now rejected rather than becoming an href. Anchoring with and is the obvious alternative and I rejected it, because it regresses URLs that validate today. The host alternation is with no colon, the path group is with no parentheses, and the query group is with no comma, so is unreachable for all of these, each of which I checked against an anchored build of the same pattern: All five pass on main, all five fail if the pattern is anchored, and all five pass with the whitespace guard. They are in the new test's accept list for that reason. Same reasoning as in #9091, which fixes the auto embed menu opening on pasted prose. That was the same failure one layer up, and it is worth noting that the two are independent: #9091 gates the embed menu, this gates whether the paste becomes a link at all. Closes #9094 Test plan New unit test , two blocks over . Accepted: , , , , , , , , and for the copied off its own line case. Rejected: (a URL sitting in the middle of a sentence), , , and . Before with the new test file against as it is on main: The ten passing cases there are the accept list, which is the point: the defect is only in the reject half, and the fix must not move the accept half. After : Whole unit project, : and report nothing on the two changed files, and reports no errors for either. The browser mode and e2e suites were not run. I did read through the paste specs for what this could move: the ones that paste a string containing a URL paste into a collapsed selection, which the listener rejects before it reaches , and the ones that paste over a selection supply either only or a single token, so none of them change their answer.
Description A node's serialized JSON is currently described three times: by , by , and by the type. The three drift, nothing checks them against each other, and every new node re-writes the same boilerplate — which is also where the domain of each property lives, so "what does this node accept when parsing?" has no answer short of reading the parser. This adds a declarative schema to that states each property once — its domain, its default, and the accessors it is reached through — and makes it the single source of truth. and are derived from it, so a node that declares a schema writes neither. Because the schema is data rather than code, three things fall out of it that hand-written methods could not give: A compact export. omits every property parsing would restore anyway — one whose value is its schema default, one the parser derives rather than reads, and the deprecated . The two forms describe the same document. It is a module-scope flag rather than an argument because the walk crosses editor boundaries: a nested editor's takes no arguments, and neither does the schema getter that reaches it, so nothing else can carry the form across. Generated implementations. compiles the schemas of the four hottest core classes into straight-line , compact and , checked in as and installed on the class at registration. Each form is a separate function, so the argument picks between two pieces of straight-line code rather than branching per property inside one. The import direction is the untrusted-JSON boundary, so every generated parser is run against the schema it was compiled from, over a corpus drawn from that schema plus the shapes hostile JSON arrives in; a property the generator cannot compile faithfully takes its class out of that half rather than shipping a parser that disagrees with the walk. Example generation.* derives a arbitrary from any node class's schema, so the values a test generates are exactly the ones the node's parser accepts, legacy spellings included. A subclass keeps control throughout. A names the accessor its direct field access stands in for, and if any class between the declaring class and the node's own overrides that accessor, the field access is abandoned and the method is called — so declaring a property to be a field is not a behavior change for anyone who had overridden its getter or setter. Nothing here is required: a node with no schema keeps its hand-written methods and is unaffected. Test plan Before Every node's serialized shape is described once per direction, with no check that the two agree and no way to ask what a property accepts: Export and parse over a 12,001 node document, production bundles, min of 40 and 30 after warmup, three runs — against the last release and against main: There is no compact form to measure, and no way to export one. After Export is 24% faster than main and parse is 5% faster. A compact export costs about what a legacy one does — it writes fewer keys but compares each value against its schema default, and the two roughly cancel. What the compact form buys, on the same document: 58% smaller uncompressed, and 3.5% larger* gzipped. That is worth stating plainly: what compaction removes is the repeated keys and default values, which is exactly the material gzip already collapses, and what is left behind compresses worse. Compact mode is for uncompressed storage — IndexedDB, size-capped fields, in-memory payloads — and is a slight loss for anything already gzipped on the wire. The benchmark asserts a compact export parses back to a byte-identical legacy export for all 12,001 nodes. Code size, gzipped production bundles: Checks: That run covers the browser project as well as the jsdom one. The E2E matrix (Chromium/Firefox/WebKit) was not exercised, since it needs the playground dev server running.
Description A markdown link whose URL contains parentheses is not imported at all, and since export writes the URL raw, a link already in the editor is destroyed by a round trip. The CommonMark-escaped spelling fails the same way: Import Both regexes describe the destination as , which rules out and outright and has no alternative for a backslash escape. CommonMark 6.3 allows both: a destination may hold balanced parentheses, or parentheses that are backslash-escaped. The handler already runs , so the transformer means to accept an escaped destination, but the pattern can never produce one. The raw destination now reads as four alternatives: a backslash with whatever follows it, a backslash that only reaches whitespace and so escapes nothing, a balanced pair of parentheses, or any other character that is not a space, a parenthesis or a backslash. It may not begin with `replacehttps://example.com/a bxhttps://example.com/a b(c)xhttps://example.com/a b>cxxxmdast-util-to-markdownxfoo)aamdast-util-from-markdownaa/urititlea)/urititlea/urititlea/uriaa))foo(and(bar))mainhttps://example.com/abxxunescapeTextmdast-util-to-markdown
mdast-util-from-markdownpackages/lexical-mdast/node_modules$convertFromMarkdownStringmainmainpackages/lexical-markdownvitest --project unitpackages/lexical-markdownMarkdown.spectsceslintprettier --checkaaaac)text trailingleading afoo [bar](/uri)ac)b\b\)cb)caaa\\\)mdast-util-from-markdown` rejects both as well. For backtracking, feeding the unanchored pattern a destination that never closes, 20000 characters of it: open parentheses 0.013 ms, backslash escapes 0.117 ms, open angle brackets 0.057 ms, escaped angle brackets 0.061 ms. All four grow linearly from 10 characters upward.
Lexical version: 0.49 Steps To Reproduce Write a function that accepts a callback and passes it an arrow function with a $-function call inside. Store the result in a variable. Example: The current behavior The rule requires that a variable name begin with the $ prefix But this is pointless, since the function result can be used both inside the lexical context and outside of it (in this case, in the React renderer) The expected behavior The rule is not triggered by a non-function variable Impact of fix Extra prefixes in variables make it difficult to immediately determine whether a lexical context function is stored there or whether this is a side effect of the linter's autofix
Description In order to enable tree-shaking we added annotations to module-scoped calls in the source, enforced by lint. This injects them at build time with a vite/rollup plugin so they don't clutter the source and encourage cargo-culting them into places where they don't belong. This transform also gives us an opportunity to inline the trivial ones into the build product since they are function calls for type inference purposes and have no utility at runtime. For example: An annotated definition is only droppable when everything it is built from is droppable too, so one unannotated call in its arguments silently pins it. The build now fails on that instead (), naming the call — and the calls that used to need a hand-written annotation for this reason, and friends, are annotated by the transform like any other. More detailed description of the implementation Module-scope calls to the side-effect-free factories (, , , , …) need a annotation for bundlers to tree-shake unused definitions. Until now those annotations lived in the sources, enforced by the ESLint rule: 442 of them across 131 files, and a lint-fix round trip for every new definition. This injects them at build time instead, from a new published package ** — Lexical's build-time compiler, whose first pass this is, with codegen for / to follow. Each pass is its own entry point (), and the root re-exports all of them: — a Vite/Rollup plugin (, so it runs after TypeScript/JSX is compiled away) — the transform itself, for bundlers with no Rollup-style plugin API / / , and — the same resolution without rewriting the module, which is how the lint rule decides It runs in (the published bundles carry the annotations exactly as before) and in (playground, examples, dev-examples). The annotations are gone from the sources, and is replaced by its inverse, , so a branch written before this migrates with instead of by hand. It only touches module-scope calls to the factories the build annotates — an annotation on a third-party factory or inside a function body was written deliberately and is left alone. Publishing it is what keeps -mode consumers whole: anyone resolving Lexical through the export condition (a vendored copy, a ed checkout) adds the same plugin and gets the same annotations — for their own extension and command definitions too. The linked-checkout docs describe the setup, and the source-mode integration fixture uses it. Which calls get annotated. The transform resolves the callee to its binding and only annotates when it is imported from / (configurable), declared with in the module or one it imports relatively, or is a method of an object marked . A same-named from your own is left alone — annotating a call that does have side effects would let a bundler drop it. A declaration is evidence on its own, so marking a factory of your own is enough; it does not also have to be named in the list. The specifier is what decides, which matters for a short name like : one imported from or declared locally without the marker is not touched, and default imports are not followed, and a block that rebinds the name is left alone inside itself. is what makes the annotations a guarantee rather than a habit: it walks the arguments of every module-scope definition and fails on a call nothing has established is side-effect free (). Calls that only run later — a callback, a body — pin nothing and are not counted, and neither are the built-ins every bundler treats as pure. Both this repo's builds turn it on. A dependency is annotated as usual but never checked — including a Lexical resolved out of through its condition, which is the case this package exists for; a definition somebody else shipped is not the building project's to fix. It found four real pins in the examples, and objects whose methods build values and touch nothing else ( in ) are now marked , so their calls are annotated by the build rather than by hand — only the outermost call of a chain, which rollup, terser and esbuild all accept as covering the whole chain. goes further for the factories whose implementation is a trivial expression over their arguments — // (identity), / (their arguments as an array) — replacing the call with that literal and removing the import left behind: A literal needs no annotation to be dropped and nothing is left to pin the definition. Calls that do not fit their form (spread arguments, a discarded result) are annotated as usual, and the call's parentheses are kept where they were doing work. is deliberately not inlined: is more bytes than a call to a minified one-character name. Each inlined factory is marked where it is defined. For a non-Lexical source that marker is what makes inlining possible at all — proves a call is safe to drop, only the marker says what it can be replaced with. Unit tests check that the markers in the tree and the transform's table agree, and that evaluating what the transform emits matches what the real function returns, so editing one of these bodies fails there rather than silently producing wrong code. is off by default in the published plugin (it assumes a matching Lexical version); Lexical's own build turns it on. Test plan , the unit suite and the integration suite pass. What the new tests cover: re-runs the transform over every built artifact and requires nothing left to annotate and nothing left to inline — the check that catches the plugin falling out of . Unit tests bundle a module with Rollup, factories in an external module so the bundler cannot infer purity itself, and assert an unused definition survives without the plugin and is dropped with it — including the nested-argument case that pins the enclosing definition, and the inlined case where no annotation is involved at all. Unit tests check that the markers in the tree and the transform's table agree, and that evaluating what the transform emits matches what the real function returns, so editing one of these bodies fails there rather than silently producing wrong code. The strict check is exercised over the whole tree by the build itself, plus unit tests for the deferred-call, pure-built-in, annotated-by-hand and dependency cases. A test holds and the declarations to each other, so a factory in the list that never says fails there (it found one: ). Tests cover which sources are trusted for a namespace, and that a shadowing block turns the annotation off for the calls inside it. The source-mode integration fixture additionally asserts that every module-scope factory call in its bundle is annotated and that no calls to the trivial factories remain. The published bundles are unchanged in substance — that is the point, the injected annotations reproduce what the hand-written ones did — and builds about 10% smaller with the annotations than without.
I've copied over the playground and made a stripped down editor for a form submission page. I HAVE NOT written any custom code yet, I've only been removing or hiding elements from the toolbar I don't need, and I've added load editor content that was saved on initial render. I've found that adding a Youtube video embed works perfectly when you're editing and doing whatever in the editor. Though the next time the editor loads with the video node inside the onloaded content the behavior of enter becomes incredibly weird, instead of a new line being added where the cursor is, the cursor will instead move to the next line. Hitting enter a second time will delete the line the cursor is on and then behavior will return to normal and everything will continue working as expected. This behavior seems to happen with image too, not sure if it's all embeddable block types of node, haven't done testing with the others since I don't need them. Lexical version: 0.49.0 Steps To Reproduce 1. Insert a Youtube embed 2. Save the editor content 3. Reload the editor with the Youtube node there 4. Enter behaves weirdly regardless of where you click or start typing from Link to code example: N/A - As another test case for playground made it should be able to load previously saved state because behavior seems to change when its a clean state and when there's content already in the editor before typing. The current behavior When lexical loads with the video node enter behavior is weird until you manage to create a new line by pressing enter twice, then it's focus and keyboard behavior will return to normal. The expected behavior When lexical loads with a video node, enter should create a new line just as it does when you're typing normally. Impact of fix How severe is this bug? Medium to High I Guess? How often does it happen? Every single time the lexical editor loads with custom content that includes the video node. The fix would benefit 10,000s of users,
Repository: facebook/lexical. Description: Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance. Stars: 23791, Forks: 2222. Primary language: TypeScript. Languages: TypeScript (79.6%), JavaScript (18%), CSS (1.5%), MDX (0.5%), HTML (0.3%). License: MIT. Homepage: https://lexical.dev Latest release: v0.49.0 (3w ago). Open PRs: 40, open issues: 299. Last activity: 3h ago. Community health: 87%. Top contributors: trueadm, zurfyx, etrepum, acywatson, fantactuka, thegreatercurve, potatowagon, mayrang, ivailop7, tylerjbainbridge and others.