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

Add support for import defer proposal #60757

Open
wants to merge 17 commits into
base: main
Choose a base branch
from

Conversation

nicolo-ribaudo
Copy link

@nicolo-ribaudo nicolo-ribaudo commented Dec 13, 2024

This proposal is currently at Stage 2.7: the last changes have been approved at the TC39 meeting last week, and I'm planning on proposing Stage 3 at the TC39 meeting on February 18th (the only blocker is me finishing writing test262 tests, and they will for sure be ready by then). I'm already opening this PR because, depending on when TS5.8 will be released, it's possible that the proposal will already be at Stage 3 by then :)

This PR only needs to add parsing support for the proposal:

  • it does not support downleveling it, as it's not possible to transpile it to older ESM versions
    • it would be possible add support for transpiling to CommonJS, if needed.
  • it does not introduce any type-checking changes, as the object created by import defer * as ns is meant to "look like" the one created by import * as ns

As you'll see from the code, whether a module is deferred or not is not a boolean but an enum. That's because of the import source proposal: there is no PR for it yet, but once it will be implemented it should be done by adding one more possible "import phase" to this enum. A question I have is if that should be an enum, or just an union of undefined | DeferKeyword (which will one day become undefined | DeferKeyword | SourceKeyword).

Fixes #59391

This patch was originally written by @ryzokuken

@typescript-bot typescript-bot added the For Uncommitted Bug PR for untriaged, rejected, closed or missing bug label Dec 13, 2024
@typescript-bot
Copy link
Collaborator

Looks like you're introducing a change to the public API surface area. If this includes breaking changes, please document them on our wiki's API Breaking Changes page.

Also, please make sure @DanielRosenwasser and @RyanCavanaugh are aware of the changes, just as a heads up.

@nicolo-ribaudo nicolo-ribaudo force-pushed the import-defer branch 2 times, most recently from 942385c to de82ce7 Compare December 13, 2024 15:45
@nicolo-ribaudo
Copy link
Author

Looks like you're introducing a change to the public API surface area. If this includes breaking changes, please document them on our wiki's API Breaking Changes page.

I guess this goes in favor of using undefined | DeferKeyword, so that it avoids a breaking change in the factory method? :)

@@ -4723,11 +4724,12 @@ export function createNodeFactory(flags: NodeFactoryFlags, baseFactory: BaseNode
}

// @api
function createImportClause(isTypeOnly: boolean, name: Identifier | undefined, namedBindings: NamedImportBindings | undefined): ImportClause {
function createImportClause(isTypeOnly: boolean, name: Identifier | undefined, namedBindings: NamedImportBindings | undefined, phase: ImportPhase): ImportClause {
Copy link
Member

Choose a reason for hiding this comment

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

Preemptively noting that this is a breaking API change, and would require a deprecation helper in the deprecations project to tack onto our public API something that will set a default for the new parameter.

Copy link
Member

Choose a reason for hiding this comment

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

It may also be the case that phase needs to go after isTypeOnly since AST node builder parameters and properties are intended to be in source order.

Copy link
Member

@rbuckton rbuckton Dec 13, 2024

Choose a reason for hiding this comment

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

I don't think we want to mix type imports with defer or source. Something like import type source foo from "foo" doesn't make sense if all source imports will have the same type, and import type defer * as foo from "foo" would essentially be the same as import type * as foo from "foo".

If we consider type, defer, and source to be mutually exclusive, then I would suggest we replace the isTypeOnly parameter with something like importModifier: SyntaxKind.TypeKeyword | SyntaxKind.DeferKeyword | SyntaxKind.SourceKeyword | boolean | undefined and have ImportClause be:

export interface ImportClause extends NamedDeclaration {
    readonly kind: SyntaxKind.ImportClause;
    readonly parent: ImportDeclaration | JSDocImportTag;
    /** @deprecated */
    readonly isTypeOnly: boolean;
    readonly importModifier: SyntaxKind.TypeKeyword | SyntaxKind.DeferKeyword | SyntaxKind.SourceKeyword | undefined;
    readonly name?: Identifier; // Default binding
    readonly namedBindings?: NamedImportBindings;
}

For back-compat purposes, we can set isTypeOnly to true if importModifier is SyntaxKind.TypeKeyword.

Copy link
Member

Choose a reason for hiding this comment

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

I think the issue with that scheme is that it's a little weirder to capture multiple modifiers being used in the cases of error recovery.

Copy link
Author

Choose a reason for hiding this comment

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

I was originally going with something very similar to Ron's suggestion, and consider "type" just as another phase. I ended up not doing it because of the AST breaking change, but if just keeping the old property for backwards compat is ok then I'd go for it.

I think the issue with that scheme is that it's a little weirder to capture multiple modifiers being used in the cases of error recovery.

Error recovery is going to be tricky anyway, because each modifier is a valid identifier so things like import type defer are a potentially valid start of an import declaration. I wonder how likely it is for people to accidentally write two modifiers in an import.

@jakebailey
Copy link
Member

I guess this goes in favor of using undefined | DeferKeyword, so that it avoids a breaking change in the factory method? :)

I'm not an expert per se, but I would expect that these need to be modifiers so that they are nodes that can be walked, since people can stick comments between them and so on...

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Dec 13, 2024

I would expect that these need to be modifiers so that they are nodes that can be walked, since people can stick comments between them and so on...

Modifiers would precede the import here, so it's a bit more iffy than that. But I guess the export @decorator class C {} thing has already made things iffy between decorators/modifiers, and @rbuckton recalls how we handled this.

But this is close syntactically to import type, which just gets an isTypeOnly property slapped on, and hasn't caused issues. When we do need to hydrate nodes in the language service, we'll end up with a synthesized defer node. So from a technical standpoint, I think it's doesn't cause issues, but it might not be the best representation long-term.

@rbuckton
Copy link
Member

rbuckton commented Dec 13, 2024

But this is close syntactically to import type, which just gets an isTypeOnly property slapped on, and hasn't caused issues.

We have emitTokenWithComment to handle synthetic punctuation and comments, so that's fine. IMO we should just use SyntaxKind instead of a separate enum, as I suggested above.

@rbuckton
Copy link
Member

rbuckton commented Dec 13, 2024

That said, ImportClause is parsed after the import keyword, so it could have modifiers and we could just make type a modifier with isTypeOnly existing purely for back-compat purposes. That would require a lot of additional complexity in how we handle modifiers, however, as we'd have to have grammar checks to disallow type, defer, and source as modifiers for most elements (though we do this for accessor, in, out, etc, so it's nothing new).

src/compiler/emitter.ts Outdated Show resolved Hide resolved
@nicolo-ribaudo
Copy link
Author

I updated the PR to go with the suggestion in #60757 (comment), which is what I found the cleanest. Happy to do differently if needed. The API change is now backwards compatible. Probably I should do the same change for exports? Even though export defer and export source don't exist, so it's only for type.

I also added a test showing that comments are properly preserved.

@nicolo-ribaudo nicolo-ribaudo marked this pull request as draft January 16, 2025 14:51
@nicolo-ribaudo
Copy link
Author

Marking as draft until I implement import.defer(), which has been confirmed to remain part of the proposal.

@nicolo-ribaudo
Copy link
Author

Oh well, that was simpler than expected. Given that import(...) is parsed as a CallExpression whose .expression is import, I'm parsing import.defer(...) as a CallExpression whose .expression is MetaProperty(import, defer) (+ some validation to make sure that it's not used as a standalone meta property, but only in the call syntax).

@nicolo-ribaudo nicolo-ribaudo marked this pull request as ready for review January 16, 2025 15:59
@@ -37642,7 +37650,7 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
error(node, Diagnostics.The_import_meta_meta_property_is_only_allowed_when_the_module_option_is_es2020_es2022_esnext_system_node16_node18_or_nodenext);
}
const file = getSourceFileOfNode(node);
Debug.assert(!!(file.flags & NodeFlags.PossiblyContainsImportMeta), "Containing file is missing import meta node flag.");
Debug.assert(node.name.escapedText === "defer" || !!(file.flags & NodeFlags.PossiblyContainsImportMeta), "Containing file is missing import meta node flag.");
return node.name.escapedText === "meta" ? getGlobalImportMetaType() : errorType;
Copy link
Member

Choose a reason for hiding this comment

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

Should import.defer be returning errorType here? Should we also report an error here for import.defer outside of a call?

Copy link
Author

Choose a reason for hiding this comment

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

I now moved the import.defer check to the checkMetaProperty, which also already calls checkGrammarMetaProperty to check if import.defer is outside of a call. I also changes isExpressionNode() to return false for the import.defer in import.defer(...).

So now this branch is only reached if we have a "standalone" import.defer, for which checkGrammarMetaProperty reports an error, and so returning errorType is correct.

src/compiler/checker.ts Outdated Show resolved Hide resolved
src/compiler/checker.ts Outdated Show resolved Hide resolved
src/compiler/checker.ts Outdated Show resolved Hide resolved
src/compiler/parser.ts Outdated Show resolved Hide resolved
src/compiler/checker.ts Outdated Show resolved Hide resolved
src/compiler/parser.ts Outdated Show resolved Hide resolved
src/compiler/parser.ts Outdated Show resolved Hide resolved
src/compiler/types.ts Outdated Show resolved Hide resolved
src/compiler/types.ts Outdated Show resolved Hide resolved
ns.foo();
});

import("./a.js"); // TODO: Without this the import.defer cannot resolve ./a
Copy link
Author

Choose a reason for hiding this comment

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

I'm going to debug this next week, but if you have any pointer as to why I would be getting an error about ./a.js not being resolved in import.defer(...) without this I'd appreciate it :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Uncommitted Bug PR for untriaged, rejected, closed or missing bug
Projects
Status: Waiting on author
Development

Successfully merging this pull request may close these issues.

import defer Stage 2.7 proposal support
7 participants