Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/services/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1860,7 +1860,36 @@ function createCompletionEntry(
&& !(type.flags & TypeFlags.BooleanLike)
&& !(type.flags & TypeFlags.Union && find((type as UnionType).types, type => !!(type.flags & TypeFlags.BooleanLike)))
) {
if (type.flags & TypeFlags.StringLike || (type.flags & TypeFlags.Union && every((type as UnionType).types, type => !!(type.flags & (TypeFlags.StringLike | TypeFlags.Undefined) || isStringAndEmptyAnonymousObjectIntersection(type))))) {
// Check if we should use quotes for string-like types
let shouldUseQuotes = false;

if (type.flags & TypeFlags.StringLike) {
// Direct string-like type
shouldUseQuotes = true;
}
else if (type.flags & TypeFlags.Union) {
const unionType = type as UnionType;
// Check if all types are string-like or undefined (original logic)
const allTypesAreStringLikeOrUndefined = every(unionType.types, type => !!(type.flags & (TypeFlags.StringLike | TypeFlags.Undefined) || isStringAndEmptyAnonymousObjectIntersection(type)));

if (allTypesAreStringLikeOrUndefined) {
shouldUseQuotes = true;
}
else {
// Check if the union contains string-like types that users would typically provide as strings
// This handles cases like Preact's Signalish<string | undefined> = string | undefined | SignalLike<string | undefined>
const hasStringLikeTypes = some(unionType.types, type => !!(type.flags & TypeFlags.StringLike));
const hasNonObjectTypes = some(unionType.types, type => !!(type.flags & (TypeFlags.StringLike | TypeFlags.Undefined | TypeFlags.Null)));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused by "has non object types"; is this not actually reporting whether or not some member is string or undefined or null?

Can you go into detail somewhere (PR description, comment, something) about what specific case the repro is hitting and what special casing is required?

Copy link
Author

@IAmArthurAdams IAmArthurAdams Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback!
I’ve renamed hasNonObjectTypes to hasPrimitiveTypes and updated the comment to better reflect what the check is actually doing.

The logic specifically checks whether the union contains both string-like types and primitive types (string, undefined, null). This distinction matters in cases like Preact’s Signalish<string | undefined> (which expands to string | undefined | SignalLike<string | undefined>):

Without ensuring a string-like type is present, TypeScript might apply this rule to unions like null | undefined, where quotes wouldn’t make sense. With this special-casing, we only switch to quoted completions ("$1") when the union actually includes string primitives.

Copy link
Member

@jakebailey jakebailey Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But number is primitive too? (We have a definition of Primitive in this codebase and it is different, so terminology is important.)

I'm just finding this PR strange because the effect is "any union that contains a string and undefined or null gets completions with quotes"). That meaning is obscured in your code because to get there the code first checks if all elements are strings/undefined, which the second branch will of course match as well.

Basically, the code here looks complicated, but is effectively equivalent to swapping every to some + only checking TypeFlags.String in the original code, which indeed passes the same tests.

And that's the core problem; Signalish<string | undefined> is string | undefined | SignalLike<string>, and Signalike<string> is an object, and so our old code rejected it because you might want to write a non-string. So this isn't a bugfix, this is a design change we'd need to weigh.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough feedback!

I agree the term “Primitive” doesn’t fit well with the repo’s existing definitions, so I could rename that variable to something clearer like hasStringishOrNullableTypes to avoid confusion.

I understand that this change is more of a design choice than a bugfix. For union types like Signalish<string | undefined>, users generally expect quoted string completions rather than brace completions, even though the union includes object-like members. The original code’s strict “all types must be string-like or undefined” check prevented that and led to a less intuitive experience.

While the new logic looks similar to swapping every for some, the goal was to cover these practical cases where quoted completions make more sense despite object members being present.

If this behavior shift feels too broad for this PR, I’m happy to revise or work on a more thorough approach based on your guidance. I’m also fine keeping this PR open whilst it is determined if this change is needed at all. 😊

Copy link
Member

@jakebailey jakebailey Aug 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anything, I would prefer if the PR were just changed to some + checking just TypeFlags.StringLike, since that's the actual change and what we'd accept if we wanted to change this.

And no offense, but I can tell that this is all LLM generated code and comment replies; the explanation ("While the new logic looks similar to swapping every for some, the goal was to cover these practical cases where quoted completions make more sense despite object members being present.") doesn't make much sense in context of what I said. It's a bit distracting.


// If the union has string-like types and at least some primitive types (not just objects),
// prefer quotes since users commonly want to provide string values
if (hasStringLikeTypes && hasNonObjectTypes) {
shouldUseQuotes = true;
}
}
}

if (shouldUseQuotes) {
// If is string like or undefined use quotes
insertText = `${escapeSnippetText(name)}=${quote(sourceFile, preferences, "$1")}`;
isSnippet = true;
Expand Down
52 changes: 52 additions & 0 deletions tests/cases/fourslash/jsxAttributeCompletionStyleAutoSignalish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/// <reference path="fourslash.ts" />

// @Filename: foo.tsx
//// declare namespace JSX {
//// interface Element { }
//// interface SignalLike<T> {
//// value: T;
//// peek(): T;
//// subscribe(fn: (value: T) => void): () => void;
//// }
//// type Signalish<T> = T | SignalLike<T>;
//// interface IntrinsicElements {
//// div: {
//// class?: Signalish<string | undefined>;
//// id?: Signalish<string | undefined>;
//// title?: Signalish<string | undefined>;
//// disabled?: Signalish<boolean | undefined>;
//// 'data-testid'?: Signalish<string | undefined>;
//// role?: Signalish<string | undefined>;
//// // For comparison - pure string type should still work
//// pureString?: string;
//// // Boolean-like should not get quotes
//// booleanProp?: boolean;
//// }
//// }
//// }
////
//// <div [|prop_/**/|] />

// Test that string-like Signalish types prefer quotes over braces
verify.completions({
marker: "",
includes: [
{
name: "class",
insertText: "class=\"$1\"",
isSnippet: true,
sortText: completion.SortText.OptionalMember,
},
{
name: "id",
insertText: "id=\"$1\"",
isSnippet: true,
sortText: completion.SortText.OptionalMember,
},
],
preferences: {
jsxAttributeCompletionStyle: "auto",
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
}
});
Loading