Repository: sveltejs/language-tools
Pull request #1163 (2021-09-04 10:05:49)

Event modifier completion/ hover
#1136 


Patch:
diff --git a/packages/language-server/src/lib/documents/parseHtml.ts b/packages/language-server/src/lib/documents/parseHtml.ts
index 7ee6b45c..cfa65ebe 100644
--- a/packages/language-server/src/lib/documents/parseHtml.ts
+++ b/packages/language-server/src/lib/documents/parseHtml.ts
@@ -90,6 +90,7 @@ function preprocess(text: string) {
 export interface AttributeContext {
     name: string;
     inValue: boolean;
+    elementTag: Node;
     valueRange?: [number, number];
 }
 
@@ -122,6 +123,7 @@ export function getAttributeContextAtPosition(
 
             if (inTokenRange()) {
                 return {
+                    elementTag: tag,
                     name: currentAttributeName,
                     inValue: false
                 };
@@ -131,6 +133,7 @@ export function getAttributeContextAtPosition(
                 const nextToken = scanner.scan();
 
                 return {
+                    elementTag: tag,
                     name: currentAttributeName,
                     inValue: true,
                     valueRange: [
@@ -151,6 +154,7 @@ export function getAttributeContextAtPosition(
                 }
 
                 return {
+                    elementTag: tag,
                     name: currentAttributeName,
                     inValue: true,
                     valueRange: [start, end]
diff --git a/packages/language-server/src/plugins/html/HTMLPlugin.ts b/packages/language-server/src/plugins/html/HTMLPlugin.ts
index 18e6a009..b2260b00 100644
--- a/packages/language-server/src/plugins/html/HTMLPlugin.ts
+++ b/packages/language-server/src/plugins/html/HTMLPlugin.ts
@@ -32,6 +32,7 @@ import {
     LinkedEditingRangesProvider
 } from '../interfaces';
 import { isInsideMoustacheTag, toRange } from '../../lib/documents/utils';
+import { possiblyComponent } from '../../utils';
 
 export class HTMLPlugin
     implements HoverProvider, CompletionsProvider, RenameProvider, LinkedEditingRangesProvider
@@ -62,7 +63,7 @@ export class HTMLPlugin
         }
 
         const node = html.findNodeAt(document.offsetAt(position));
-        if (!node || this.possiblyComponent(node)) {
+        if (!node || possiblyComponent(node)) {
             return null;
         }
 
@@ -102,12 +103,23 @@ export class HTMLPlugin
                 )
             ]);
         }
+
         const results = this.isInComponentTag(html, document, position)
             ? // Only allow emmet inside component element tags.
               // Other attributes/events would be false positives.
               CompletionList.create([])
             : this.lang.doComplete(document, position, html);
         const items = this.toCompletionItems(results.items);
+
+        items.forEach((item) => {
+            if (item.label.startsWith('on:') && item.textEdit) {
+                item.textEdit = {
+                    ...item.textEdit,
+                    newText: item.textEdit.newText.replace('="$1"', '$2="$1"')
+                };
+            }
+        });
+
         return CompletionList.create(
             [
                 ...this.toCompletionItems(items),
@@ -225,7 +237,7 @@ export class HTMLPlugin
         }
 
         const node = html.findNodeAt(document.offsetAt(position));
-        if (!node || this.possiblyComponent(node)) {
+        if (!node || possiblyComponent(node)) {
             return null;
         }
 
@@ -244,12 +256,7 @@ export class HTMLPlugin
 
         const offset = document.offsetAt(position);
         const node = html.findNodeAt(offset);
-        if (
-            !node ||
-            this.possiblyComponent(node) ||
-            !node.tag ||
-            !this.isRenameAtTag(node, offset)
-        ) {
+        if (!node || possiblyComponent(node) || !node.tag || !this.isRenameAtTag(node, offset)) {
             return null;
         }
         const tagNameStart = node.start + '<'.length;
@@ -276,16 +283,6 @@ export class HTMLPlugin
         return { ranges };
     }
 
-    /**
-     *
-     * The language service is case insensitive, and would provide
-     * hover info for Svelte components like `Option` which have
-     * the same name like a html tag.
-     */
-    private possiblyComponent(node: Node): boolean {
-        return !!node.tag?.[0].match(/[A-Z]/);
-    }
-
     /**
      * Returns true if rename happens at the tag name, not anywhere inbetween.
      */
diff --git a/packages/language-server/src/plugins/svelte/SveltePlugin.ts b/packages/language-server/src/plugins/svelte/SveltePlugin.ts
index 34901f14..4233bcfb 100644
--- a/packages/language-server/src/plugins/svelte/SveltePlugin.ts
+++ b/packages/language-server/src/plugins/svelte/SveltePlugin.ts
@@ -160,7 +160,7 @@ export class SveltePlugin
             return null;
         }
 
-        return getCompletions(svelteDoc, position);
+        return getCompletions(document, svelteDoc, position);
     }
 
     async doHover(document: Document, position: Position): Promise<Hover | null> {
@@ -168,7 +168,7 @@ export class SveltePlugin
             return null;
         }
 
-        return getHoverInfo(await this.getSvelteDoc(document), position);
+        return getHoverInfo(document, await this.getSvelteDoc(document), position);
     }
 
     async getCodeActions(
diff --git a/packages/language-server/src/plugins/svelte/features/getCompletions.ts b/packages/language-server/src/plugins/svelte/features/getCompletions.ts
index 8b16a17b..4cef1b66 100644
--- a/packages/language-server/src/plugins/svelte/features/getCompletions.ts
+++ b/packages/language-server/src/plugins/svelte/features/getCompletions.ts
@@ -5,10 +5,14 @@ import {
     CompletionList,
     CompletionItemKind,
     CompletionItem,
-    InsertTextFormat
+    InsertTextFormat,
+    MarkupKind
 } from 'vscode-languageserver';
 import { SvelteTag, documentation, getLatestOpeningTag } from './SvelteTags';
-import { isInTag } from '../../../lib/documents';
+import { isInTag, Document } from '../../../lib/documents';
+import { AttributeContext, getAttributeContextAtPosition } from '../../../lib/documents/parseHtml';
+import { getModifierData } from './getModifierData';
+import { attributeCanHaveEventModifier } from './utils';
 
 const HTML_COMMENT_START = '<!--';
 
@@ -26,6 +30,7 @@ const componentDocumentationCompletion: CompletionItem = {
 };
 
 export function getCompletions(
+    document: Document,
     svelteDoc: SvelteDocument,
     position: Position
 ): CompletionList | null {
@@ -39,18 +44,22 @@ export function getCompletions(
         .getText()
         // use last 10 characters, should cover 99% of all cases
         .substr(Math.max(offset - 10, 0), Math.min(offset, 10));
-    const notPreceededByOpeningBracket = !/[\s\S]*{\s*[#:/@]\w*$/.test(
-        lastCharactersBeforePosition
-    );
+    const precededByOpeningBracket = /[\s\S]*{\s*[#:/@]\w*$/.test(lastCharactersBeforePosition);
     if (isInStyleOrScript) {
         return null;
     }
 
-    if (notPreceededByOpeningBracket) {
-        return getComponentDocumentationCompletions();
+    if (precededByOpeningBracket) {
+        return getTagCompletionsWithinMoustache();
+    }
+
+    const attributeContext = getAttributeContextAtPosition(document, position);
+
+    if (attributeContext) {
+        return getEventModifierCompletion(attributeContext);
     }
 
-    return getTagCompletionsWithinMoustache();
+    return getComponentDocumentationCompletions();
 
     /**
      * Get completions for special svelte tags within moustache tags.
@@ -78,6 +87,32 @@ export function getCompletions(
     }
 }
 
+function getEventModifierCompletion(attributeContext: AttributeContext): CompletionList | null {
+    const modifiers = getModifierData();
+
+    if (!attributeContext || !attributeCanHaveEventModifier(attributeContext)) {
+        return null;
+    }
+
+    const items = modifiers
+        .filter(
+            (modifier) =>
+                !attributeContext.name.includes('|' + modifier.modifier) &&
+                !modifier.modifiersInvalidWith?.some((invalidWith) =>
+                    attributeContext.name.includes(invalidWith)
+                )
+        )
+        .map(
+            (m): CompletionItem => ({
+                label: m.modifier,
+                documentation: m.documentation,
+                kind: CompletionItemKind.Event
+            })
+        );
+
+    return CompletionList.create(items);
+}
+
 /**
  * Get completions with regard to trigger character.
  */
@@ -207,7 +242,7 @@ function createCompletionItems(
                     kind: CompletionItemKind.Keyword,
                     preselect: true,
                     documentation: {
-                        kind: 'markdown',
+                        kind: MarkupKind.Markdown,
                         value: documentation[item.tag]
                     }
                 }
diff --git a/packages/language-server/src/plugins/svelte/features/getHoverInfo.ts b/packages/language-server/src/plugins/svelte/features/getHoverInfo.ts
index cc12537a..c8100849 100644
--- a/packages/language-server/src/plugins/svelte/features/getHoverInfo.ts
+++ b/packages/language-server/src/plugins/svelte/features/getHoverInfo.ts
@@ -2,12 +2,19 @@ import { Hover, Position } from 'vscode-languageserver';
 import { SvelteDocument } from '../SvelteDocument';
 import { documentation, SvelteTag, getLatestOpeningTag } from './SvelteTags';
 import { flatten } from '../../../utils';
-import { isInTag } from '../../../lib/documents';
+import { Document, isInTag } from '../../../lib/documents';
+import { AttributeContext, getAttributeContextAtPosition } from '../../../lib/documents/parseHtml';
+import { attributeCanHaveEventModifier } from './utils';
+import { getModifierData } from './getModifierData';
 
 /**
  * Get hover information for special svelte tags within moustache tags.
  */
-export function getHoverInfo(svelteDoc: SvelteDocument, position: Position): Hover | null {
+export function getHoverInfo(
+    document: Document,
+    svelteDoc: SvelteDocument,
+    position: Position
+): Hover | null {
     const offset = svelteDoc.offsetAt(position);
 
     const isInStyleOrScript =
@@ -20,13 +27,34 @@ export function getHoverInfo(svelteDoc: SvelteDocument, position: Position): Hov
         .getText()
         // use last 10 and next 10 characters, should cover 99% of all cases
         .substr(offsetStart, 20);
-    const isNoSvelteTag = !tagRegexp.test(charactersAroundOffset);
+    const isSvelteTag = tagRegexp.test(charactersAroundOffset);
+
+    if (isInStyleOrScript) {
+        return null;
+    }
+
+    if (isSvelteTag) {
+        return getTagHoverInfoAtOffset(svelteDoc, offsetStart, charactersAroundOffset, offset);
+    }
 
-    if (isInStyleOrScript || isNoSvelteTag) {
+    const attributeContext = getAttributeContextAtPosition(document, position);
+
+    if (!attributeContext || !attributeCanHaveEventModifier(attributeContext)) {
         return null;
     }
 
-    const tag = getTagAtOffset(svelteDoc, offsetStart, charactersAroundOffset, offset);
+    const attributeOffset = svelteDoc.getText().lastIndexOf(attributeContext.name, offset);
+
+    return getEventModifierHoverInfo(attributeContext, attributeOffset, offset);
+}
+
+function getTagHoverInfoAtOffset(
+    svelteDoc: SvelteDocument,
+    charactersOffset: number,
+    charactersAroundOffset: string,
+    offset: number
+): Hover | null {
+    const tag = getTagAtOffset(svelteDoc, charactersOffset, charactersAroundOffset, offset);
     if (!tag) {
         return null;
     }
@@ -91,3 +119,24 @@ const tagPossibilities: Array<{ tag: SvelteTag | ':else'; values: string[] }> =
 const tagRegexp = new RegExp(
     `[\\s\\S]*{\\s*(${flatten(tagPossibilities.map((p) => p.values)).join('|')})(\\s|})`
 );
+function getEventModifierHoverInfo(
+    attributeContext: AttributeContext,
+    attributeOffset: number,
+    offset: number
+): Hover | null {
+    const { name } = attributeContext;
+
+    const modifierData = getModifierData();
+
+    const found = modifierData.find((modifier) =>
+        isAroundOffset(attributeOffset, name, modifier.modifier, offset)
+    );
+
+    if (!found) {
+        return null;
+    }
+
+    return {
+        contents: found.documentation
+    };
+}
diff --git a/packages/language-server/src/plugins/svelte/features/getModifierData.ts b/packages/language-server/src/plugins/svelte/features/getModifierData.ts
new file mode 100644
index 00000000..27b183c4
--- /dev/null
+++ b/packages/language-server/src/plugins/svelte/features/getModifierData.ts
@@ -0,0 +1,63 @@
+import { MarkupContent, MarkupKind } from 'vscode-languageserver';
+
+export interface ModifierData {
+    modifier: string;
+    documentation: MarkupContent;
+    modifiersInvalidWith?: string[];
+}
+
+export function getModifierData(): ModifierData[] {
+    return [
+        {
+            modifier: 'preventDefault',
+            documentation: 'calls `event.preventDefault()` before running the handler',
+            modifiersInvalidWith: ['passive']
+        },
+        {
+            modifier: 'stopPropagation',
+            documentation:
+                'calls `event.stopPropagation()`, preventing the event reaching the next element'
+        },
+        {
+            modifier: 'passive',
+            documentation:
+                'improves scrolling performance on touch/wheel events ' +
+                "(Svelte will add it automatically where it's safe to do so)",
+            modifiersInvalidWith: ['nopassive', 'preventDefault']
+        },
+        {
+            modifier: 'nonpassive',
+            documentation: 'explicitly set `passive: false`',
+            modifiersInvalidWith: ['passive']
+        },
+        {
+            modifier: 'capture',
+            documentation:
+                'fires the handler during the capture phase instead of the bubbling phase'
+        },
+        {
+            modifier: 'once',
+            documentation: 'remove the handler after the first time it runs'
+        },
+        {
+            modifier: 'self',
+            documentation: 'only trigger handler if `event.target` is the element itself'
+        },
+        {
+            modifier: 'trusted',
+            documentation:
+                'only trigger handler if event.isTrusted is true. ' +
+                'I.e. if the event is triggered by a user action'
+        }
+    ].map((item) => ({
+        ...item,
+        documentation: {
+            kind: MarkupKind.Markdown,
+            value: `\`${item.modifier}\` event modifier
+
+${item.documentation}
+
+https://svelte.dev/docs#on_element_event`
+        }
+    }));
+}
diff --git a/packages/language-server/src/plugins/svelte/features/utils.ts b/packages/language-server/src/plugins/svelte/features/utils.ts
new file mode 100644
index 00000000..9a1619cb
--- /dev/null
+++ b/packages/language-server/src/plugins/svelte/features/utils.ts
@@ -0,0 +1,11 @@
+import { AttributeContext } from '../../../lib/documents/parseHtml';
+import { possiblyComponent } from '../../../utils';
+
+export function attributeCanHaveEventModifier(attributeContext: AttributeContext) {
+    return (
+        !attributeContext.inValue &&
+        !possiblyComponent(attributeContext.elementTag) &&
+        attributeContext.name.startsWith('on:') &&
+        attributeContext.name.includes('|')
+    );
+}
diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts
index 22f19277..140d38e8 100644
--- a/packages/language-server/src/server.ts
+++ b/packages/language-server/src/server.ts
@@ -188,7 +188,8 @@ export function startServer(options?: LSOptions) {
                         // of other completion providers
 
                         // Svelte
-                        ':'
+                        ':',
+                        '|'
                     ]
                 },
                 documentFormattingProvider: true,
diff --git a/packages/language-server/src/utils.ts b/packages/language-server/src/utils.ts
index 6ebe8ddb..3f8f98e6 100644
--- a/packages/language-server/src/utils.ts
+++ b/packages/language-server/src/utils.ts
@@ -1,5 +1,6 @@
 import { URI } from 'vscode-uri';
 import { Position, Range } from 'vscode-languageserver';
+import { Node } from 'vscode-html-languageservice';
 
 export function clamp(num: number, min: number, max: number): number {
     return Math.max(min, Math.min(max, num));
@@ -226,3 +227,16 @@ export async function filterAsync<T>(
 export function getIndent(text: string) {
     return /^[ |\t]+/.exec(text)?.[0] ?? '';
 }
+
+/**
+ *
+ * The html language service is case insensitive, and would provide
+ * hover/ completion info for Svelte components like `Option` which have
+ * the same name like a html tag.
+ *
+ * Also, svelte directives like action and event modifier only work
+ * with element not component
+ */
+export function possiblyComponent(node: Node): boolean {
+    return !!node.tag?.[0].match(/[A-Z]/);
+}
diff --git a/packages/svelte2tsx/src/svelte2tsx/nodes/ImplicitTopLevelNames.ts b/packages/svelte2tsx/src/svelte2tsx/nodes/ImplicitTopLevelNames.ts
index fb859f86..5073ffc0 100644
--- a/packages/svelte2tsx/src/svelte2tsx/nodes/ImplicitTopLevelNames.ts
+++ b/packages/svelte2tsx/src/svelte2tsx/nodes/ImplicitTopLevelNames.ts
@@ -38,8 +38,13 @@ export class ImplicitTopLevelNames {
         const start = expression.getStart() + this.astOffset;
         const end = expression.getEnd() + this.astOffset;
 
-        // $: a = { .. }  /  $: a = .. as ..  =>   () => ( .. )
-        if (ts.isObjectLiteralExpression(expression) || ts.isAsExpression(expression)) {
+        // $: a = { .. }..  /  $: a = .. as ..  =>   () => ( .. )
+        if (
+            ts.isObjectLiteralExpression(expression) ||
+            (expression.getText().startsWith('{') &&
+                this.isNodeStartsWithObjectLiteral(expression)) ||
+            ts.isAsExpression(expression)
+        ) {
             this.str.appendLeft(start, '(');
             this.str.appendRight(end, ')');
         }
@@ -50,6 +55,29 @@ export class ImplicitTopLevelNames {
         // in situations where there is a line break of ; guaranteed to be present (else the code is invalid)
     }
 
+    private isNodeStartsWithObjectLiteral(node: ts.Node) {
+        if (ts.isObjectLiteralExpression(node)) {
+            return true;
+        }
+
+        if (ts.isElementAccessExpression(node)) {
+            return this.isNodeStartsWithObjectLiteral(node.expression);
+        }
+
+        if (ts.isBinaryExpression(node)) {
+            return this.isNodeStartsWithObjectLiteral(node.left);
+        }
+
+        if (ts.isConditionalExpression(node)) {
+            return this.isNodeStartsWithObjectLiteral(node.condition);
+        }
+
+        return node
+            .getChildren()
+            .filter((e) => e.pos === node.pos)
+            .some((child) => this.isNodeStartsWithObjectLiteral(child));
+    }
+
     modifyCode(rootVariables: Set<string>) {
         for (const node of this.map.values()) {
             const names = getNamesFromLabeledStatement(node);
diff --git a/packages/svelte2tsx/src/svelte2tsx/utils/tsAst.ts b/packages/svelte2tsx/src/svelte2tsx/utils/tsAst.ts
index 5bc60743..3cddb049 100644
--- a/packages/svelte2tsx/src/svelte2tsx/utils/tsAst.ts
+++ b/packages/svelte2tsx/src/svelte2tsx/utils/tsAst.ts
@@ -87,6 +87,9 @@ export function extractIdentifiers(
             } else if (ts.isShorthandPropertyAssignment(child)) {
                 // in ts Ast { a = 1 } and { a } are both ShorthandPropertyAssignment
                 extractIdentifiers(child.name, identifiers);
+            } else if (ts.isPropertyAssignment(child)) {
+                // { a: b }
+                extractIdentifiers(child.initializer, identifiers);
             }
         });
     } else if (ts.isArrayLiteralExpression(node)) {


Test patch:
diff --git a/packages/language-server/test/plugins/svelte/features/getCompletions.test.ts b/packages/language-server/test/plugins/svelte/features/getCompletions.test.ts
index f7a26230..6e428f25 100644
--- a/packages/language-server/test/plugins/svelte/features/getCompletions.test.ts
+++ b/packages/language-server/test/plugins/svelte/features/getCompletions.test.ts
@@ -4,14 +4,16 @@ import { Position } from 'vscode-languageserver';
 import { getCompletions } from '../../../../src/plugins/svelte/features/getCompletions';
 import { SvelteDocument } from '../../../../src/plugins/svelte/SvelteDocument';
 import { Document } from '../../../../src/lib/documents';
+import { getModifierData } from '../../../../src/plugins/svelte/features/getModifierData';
 
 describe('SveltePlugin#getCompletions', () => {
     function expectCompletionsFor(
         content: string,
         position: Position = Position.create(0, content.length)
     ) {
-        const svelteDoc = new SvelteDocument(new Document('url', content));
-        const completions = getCompletions(svelteDoc, position);
+        const document = new Document('url', content);
+        const svelteDoc = new SvelteDocument(document);
+        const completions = getCompletions(document, svelteDoc, position);
         return {
             toEqual: (expectedLabels: string[] | null) =>
                 assert.deepStrictEqual(
@@ -123,8 +125,43 @@ describe('SveltePlugin#getCompletions', () => {
 
     it('should return completion for component documentation comment', () => {
         const content = '<!--@';
-        const svelteDoc = new SvelteDocument(new Document('url', content));
-        const completions = getCompletions(svelteDoc, Position.create(0, content.length));
+        const document = new Document('url', content);
+        const svelteDoc = new SvelteDocument(document);
+        const completions = getCompletions(document, svelteDoc, Position.create(0, content.length));
         assert.deepStrictEqual(completions?.items?.[0].insertText, `component${EOL}$1${EOL}`);
     });
+
+    function expectCompletionsForModifier(
+        content: string,
+        position = Position.create(0, content.lastIndexOf('|') + 1)
+    ) {
+        return expectCompletionsFor(content, position);
+    }
+
+    describe('should return completion for event modifier', () => {
+        const modifierData = getModifierData();
+        const allModifiers = modifierData.map((modifier) => modifier.modifier);
+
+        it('can provides modifiers', () => {
+            expectCompletionsForModifier('<div on:click| />').toEqual(allModifiers);
+        });
+
+        it('can chain modifier and does not provide duplicated modifier', () => {
+            expectCompletionsForModifier('<div on:click|stopPropagation| />').toEqual(
+                allModifiers.filter((modifier) => modifier !== 'stopPropagation')
+            );
+        });
+
+        it("can chain modifier and does not provide modifier that can't used together", () => {
+            expectCompletionsForModifier('<div on:click|preventDefault| />').toEqual(
+                modifierData
+                    .filter(
+                        (modifier) =>
+                            modifier.modifier != 'preventDefault' &&
+                            !modifier.modifiersInvalidWith?.includes('preventDefault')
+                    )
+                    .map((modifier) => modifier.modifier)
+            );
+        });
+    });
 });
diff --git a/packages/language-server/test/plugins/svelte/features/getHoverInfo.test.ts b/packages/language-server/test/plugins/svelte/features/getHoverInfo.test.ts
index c49de229..bd01df60 100644
--- a/packages/language-server/test/plugins/svelte/features/getHoverInfo.test.ts
+++ b/packages/language-server/test/plugins/svelte/features/getHoverInfo.test.ts
@@ -4,11 +4,13 @@ import { getHoverInfo } from '../../../../src/plugins/svelte/features/getHoverIn
 import { SvelteDocument } from '../../../../src/plugins/svelte/SvelteDocument';
 import { documentation, SvelteTag } from '../../../../src/plugins/svelte/features/SvelteTags';
 import { Document } from '../../../../src/lib/documents';
+import { getModifierData } from '../../../../src/plugins/svelte/features/getModifierData';
 
 describe('SveltePlugin#getHoverInfo', () => {
     function expectHoverInfoFor(content: string, position: Position) {
-        const svelteDoc = new SvelteDocument(new Document('url', content));
-        const hover = getHoverInfo(svelteDoc, position);
+        const document = new Document('url', content);
+        const svelteDoc = new SvelteDocument(document);
+        const hover = getHoverInfo(document, svelteDoc, position);
         return {
             toEqual: (tag: SvelteTag | null) =>
                 assert.deepStrictEqual(hover, tag ? { contents: documentation[tag] } : null)
@@ -97,4 +99,25 @@ describe('SveltePlugin#getHoverInfo', () => {
             });
         });
     });
+
+    function expectHoverInfoForEventModifier(content: string, position: Position) {
+        const document = new Document('url', content);
+        const svelteDoc = new SvelteDocument(document);
+        const hover = getHoverInfo(document, svelteDoc, position);
+        return {
+            toEqual: (expectedModifier: string) => {
+                const contents = getModifierData().find(
+                    (modifier) => modifier.modifier === expectedModifier
+                )?.documentation;
+                assert.deepStrictEqual(hover, { contents });
+            }
+        };
+    }
+
+    it('should return hover event modifier', () => {
+        expectHoverInfoForEventModifier(
+            '<div on:click|preventDefault />',
+            Position.create(0, 15)
+        ).toEqual('preventDefault');
+    });
 });
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/expected.tsx b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/expected.tsx
new file mode 100644
index 00000000..110af63a
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/expected.tsx
@@ -0,0 +1,13 @@
+///<reference types="svelte" />
+<></>;function render() {
+
+let  { a } = __sveltets_1_invalidate(() => ({ a: '' }));
+let  { b: d } = __sveltets_1_invalidate(() => ({ b: '' }));
+let  { c: { length } } = __sveltets_1_invalidate(() => ({ c: '' }));
+let  { ...e } = __sveltets_1_invalidate(() => ({ f: ''}));
+;
+() => (<></>);
+return { props: {}, slots: {}, getters: {}, events: {} }}
+
+export default class Input__SvelteComponent_ extends __sveltets_1_createSvelte2TsxComponent(__sveltets_1_partial(__sveltets_1_with_any_event(render()))) {
+}
\ No newline at end of file
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/input.svelte b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/input.svelte
new file mode 100644
index 00000000..73646101
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-destructuring/input.svelte
@@ -0,0 +1,6 @@
+<script>
+$: ({ a } = { a: '' });
+$: ({ b: d } = { b: '' });
+$: ({ c: { length } } = { c: '' });
+$: ({ ...e } = { f: ''});
+</script>
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/expected.tsx b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/expected.tsx
new file mode 100644
index 00000000..fab7b792
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/expected.tsx
@@ -0,0 +1,15 @@
+///<reference types="svelte" />
+<></>;function render() {
+
+
+let  b = __sveltets_1_invalidate(() => ({ a: 1 }['a']));
+let  c = __sveltets_1_invalidate(() => ({ a: { b: 1} }['a']['b']));
+let  d = __sveltets_1_invalidate(() => ({ a: { b: 1} }['a']['b']?.['c']));
+let  e = __sveltets_1_invalidate(() => ({a: 1} ?? { a: 1 }));
+let  f = __sveltets_1_invalidate(() => ({a: 1}[c] ? '' : '1'));
+;
+() => (<></>);
+return { props: {}, slots: {}, getters: {}, events: {} }}
+
+export default class Input__SvelteComponent_ extends __sveltets_1_createSvelte2TsxComponent(__sveltets_1_partial(__sveltets_1_with_any_event(render()))) {
+}
\ No newline at end of file
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/input.svelte b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/input.svelte
new file mode 100644
index 00000000..68eb3660
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/reactive-declare-express-starts-with-object/input.svelte
@@ -0,0 +1,8 @@
+<script>
+
+$: b = { a: 1 }['a'];
+$: c = { a: { b: 1} }['a']['b'];
+$: d = { a: { b: 1} }['a']['b']?.['c'];
+$: e = {a: 1} ?? { a: 1 };
+$: f = {a: 1}[c] ? '' : '1';
+</script>