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 staticModifiers option #1021

Merged
merged 1 commit into from
Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ return require('@embroider/compat').compatBuild(app, Webpack, {
// staticAddonTestSupportTrees: true,
// staticAddonTrees: true,
// staticHelpers: true,
// staticModifiers: true,
// staticComponents: true,
// splitAtRoutes: ['route.name'], // can also be a RegExp
// packagerOptions: {
Expand All @@ -95,7 +96,7 @@ The recommended steps when introducing Embroider into an existing app are:

1. First make it work with no options. This is the mode that supports maximum backward compatibility.
2. Enable `staticAddonTestSupportTrees` and `staticAddonTrees` and test your application. This is usually safe, because most code in these trees gets consumed via `import` statements that we can analyze. But you might find exceptional cases where some code is doing a more dynamic thing.
3. Enable `staticHelpers` and test. This is usually safe because addons get invoke declarative in templates and we can see all invocations.
3. Enable `staticHelpers` and `staticModifiers` and test. This is usually safe because addon helpers and modifiers get invoked declaratively in templates and we can see all invocations.
4. Enable `staticComponents`, and work to eliminate any resulting build warnings about dynamic component invocation. You may need to add `packageRules` that declare where invocations like `{{component someComponent}}` are getting `someComponent` from.
5. Once your app is working with all of the above, you can enable `splitAtRoutes` and add the `@embroider/router` and code splitting should work.

Expand Down
1 change: 1 addition & 0 deletions packages/compat/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const recommendedOptions: { [name: string]: Options } = Object.freeze({
staticAddonTrees: true,
staticAddonTestSupportTrees: true,
staticHelpers: true,
staticModifiers: true,
staticComponents: true,
allowUnsafeDynamicComponents: false,
}),
Expand Down
26 changes: 25 additions & 1 deletion packages/compat/src/resolver-transform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { default as Resolver, ComponentResolution, ComponentLocator } from './resolver';
import type { ASTv1 } from '@glimmer/syntax';

// This is the AST transform that resolves components and helpers at build time
// This is the AST transform that resolves components, helpers and modifiers at build time
// and puts them into `dependencies`.
export function makeResolverTransform(resolver: Resolver) {
function resolverTransform({ filename }: { filename: string }) {
Expand Down Expand Up @@ -111,6 +111,30 @@ export function makeResolverTransform(resolver: Resolver) {
}
}
},
ElementModifierStatement(node: ASTv1.ElementModifierStatement) {
if (node.path.type !== 'PathExpression') {
return;
}
if (scopeStack.inScope(node.path.parts[0])) {
return;
}
if (node.path.this === true) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

we need same for node.path.data === true ({{@foobar}})

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks, didn't know about that!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added! I also forgot about "contextual" modifiers so I added that as well.

Copy link
Collaborator

@lifeart lifeart Nov 17, 2021

Choose a reason for hiding this comment

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

@Windvis could we cover "modifier" helper usage?

like

{{#let (modifier "foo-bar" arg param=1) as |my-modifier|}}
   <div {{my-modifier}} ></div>
{{/let}}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@lifeart There is a separate issue for that already: #1018

I think it's better to split that up in a dedicated PR (which can also add support for the helper helper)?

return;
}
if (node.path.data === true) {
return;
}
if (node.path.parts.length > 1) {
// paths with a dot in them (which therefore split into more than
// one "part") are classically understood by ember to be contextual
// components. With the introduction of `Template strict mode` in Ember 3.25
// it is also possible to pass modifiers this way which means there's nothing
// to resolve at this location.
return;
}

resolver.resolveElementModifierStatement(node.path.original, filename, node.path.loc);
},
ElementNode: {
enter(node: ASTv1.ElementNode) {
if (!scopeStack.inScope(node.tag.split('.')[0])) {
Expand Down
81 changes: 76 additions & 5 deletions packages/compat/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ export interface HelperResolution {
modules: ResolvedDep[];
}

export type ResolutionResult = ComponentResolution | HelperResolution;
export interface ModifierResolution {
type: 'modifier';
modules: ResolvedDep[];
}

export type ResolutionResult = ComponentResolution | HelperResolution | ModifierResolution;

export interface ResolutionFail {
type: 'error';
Expand Down Expand Up @@ -104,14 +109,20 @@ const builtInHelpers = [

const builtInComponents = ['input', 'link-to', 'textarea'];

const builtInModifiers = ['action', 'on'];

// this is a subset of the full Options. We care about serializability, and we
// only needs parts that are easily serializable, which is why we don't keep the
// whole thing.
type ResolverOptions = Pick<Required<Options>, 'staticHelpers' | 'staticComponents' | 'allowUnsafeDynamicComponents'>;
type ResolverOptions = Pick<
Required<Options>,
'staticHelpers' | 'staticModifiers' | 'staticComponents' | 'allowUnsafeDynamicComponents'
>;

function extractOptions(options: Required<Options> | ResolverOptions): ResolverOptions {
return {
staticHelpers: options.staticHelpers,
staticModifiers: options.staticModifiers,
staticComponents: options.staticComponents,
allowUnsafeDynamicComponents: options.allowUnsafeDynamicComponents,
};
Expand Down Expand Up @@ -334,13 +345,13 @@ export default class CompatResolver implements Resolver {

astTransformer(templateCompiler: TemplateCompiler): unknown {
this.templateCompiler = templateCompiler;
if (this.staticComponentsEnabled || this.staticHelpersEnabled) {
if (this.staticComponentsEnabled || this.staticHelpersEnabled || this.staticModifiersEnabled) {
return makeResolverTransform(this);
}
}

// called by our audit tool. Forces staticComponents and staticHelpers to
// activate so we can audit their behavior, while making their errors silent
// called by our audit tool. Forces staticComponents, staticHelpers and staticModifiers
// to activate so we can audit their behavior, while making their errors silent
// until we can gather them up at the end of the build for the audit results.
enableAuditMode() {
this.auditMode = true;
Expand Down Expand Up @@ -436,6 +447,10 @@ export default class CompatResolver implements Resolver {
return this.params.options.staticHelpers || this.auditMode;
}

private get staticModifiersEnabled(): boolean {
return this.params.options.staticModifiers || this.auditMode;
}

private tryHelper(path: string, from: string): Resolution | null {
let parts = path.split('@');
if (parts.length > 1 && parts[0].length > 0) {
Expand Down Expand Up @@ -470,6 +485,40 @@ export default class CompatResolver implements Resolver {
return null;
}

private tryModifier(path: string, from: string): Resolution | null {
let parts = path.split('@');
if (parts.length > 1 && parts[0].length > 0) {
let cache = PackageCache.shared('embroider-stage3');
let packageName = parts[0];
let renamed = this.adjustImportsOptions.renamePackages[packageName];
if (renamed) {
packageName = renamed;
}
return this._tryModifier(parts[1], from, cache.resolve(packageName, cache.ownerOfFile(from)!));
} else {
return this._tryModifier(path, from, this.appPackage);
}
}

private _tryModifier(path: string, from: string, targetPackage: Package | AppPackagePlaceholder): Resolution | null {
for (let extension of this.adjustImportsOptions.resolvableExtensions) {
let absPath = join(targetPackage.root, 'modifiers', path) + extension;
if (pathExistsSync(absPath)) {
return {
type: 'modifier',
modules: [
{
runtimeName: this.absPathToRuntimeName(absPath, targetPackage),
path: explicitRelative(dirname(from), absPath),
absPath,
},
],
};
}
}
return null;
}

@Memoize()
private get appPackage(): AppPackagePlaceholder {
return { root: this.params.root, name: this.params.modulePrefix };
Expand Down Expand Up @@ -651,6 +700,28 @@ export default class CompatResolver implements Resolver {
}
}

resolveElementModifierStatement(path: string, from: string, loc: Loc): Resolution | null {
if (!this.staticModifiersEnabled) {
return null;
}
let found = this.tryModifier(path, from);
if (found) {
return this.add(found, from);
}
if (builtInModifiers.includes(path)) {
return null;
}
return this.add(
{
type: 'error',
message: `Missing modifier`,
detail: path,
loc,
},
from
);
}

resolveElement(tagName: string, from: string, loc: Loc): Resolution | null {
if (!this.staticComponentsEnabled) {
return null;
Expand Down
7 changes: 6 additions & 1 deletion packages/compat/tests/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ describe('audit', function () {
resolver: new CompatResolver({
root: app.baseDir,
modulePrefix: 'audit-this-app',
options: { staticComponents: false, staticHelpers: false, allowUnsafeDynamicComponents: false },
options: {
staticComponents: false,
staticHelpers: false,
staticModifiers: false,
allowUnsafeDynamicComponents: false,
},
activePackageRules: [],
adjustImportsOptions: {
renamePackages: {},
Expand Down
77 changes: 75 additions & 2 deletions packages/compat/tests/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,59 @@ describe('compat-resolver', function () {
},
]);
});
test('missing modifier', function () {
let findDependencies = configure({ staticModifiers: true });
expect(() => {
findDependencies('templates/application.hbs', `<canvas {{fancy-drawing}}></canvas>`);
}).toThrow(new RegExp(`Missing modifier: fancy-drawing in templates/application.hbs`));
});
test('emits no modifiers when staticModifiers is off', function () {
let findDependencies = configure({ staticModifiers: false });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('templates/application.hbs', `<input {{auto-focus}} />`)).toEqual([]);
});
test('modifier on html element', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('templates/application.hbs', `<input {{auto-focus}} />`)).toEqual([
{
runtimeName: 'the-app/modifiers/auto-focus',
path: '../modifiers/auto-focus.js',
},
]);
});
test('modifier on component', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('templates/application.hbs', `<StyledInput {{auto-focus}} />`)).toEqual([
{
runtimeName: 'the-app/modifiers/auto-focus',
path: '../modifiers/auto-focus.js',
},
]);
});
test('modifier on contextual component', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('templates/application.hbs', `<Form as |f|> <f.Input {{auto-focus}} /></Form>`)).toEqual([
{
runtimeName: 'the-app/modifiers/auto-focus',
path: '../modifiers/auto-focus.js',
},
]);
});
test('modifier provided as an argument', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('components/test.hbs', `<input {{@auto-focus}} />`)).toEqual([]);
});
test('contextual modifier', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/auto-focus.js');
expect(findDependencies('templates/application.hbs', `<Form as |f|> <input {{f.auto-focus}} /></Form>`)).toEqual(
[]
);
});
test('local binding takes precedence over helper in bare mustache', function () {
let findDependencies = configure({ staticHelpers: true });
givenFile('helpers/capitalize.js');
Expand All @@ -759,6 +812,16 @@ describe('compat-resolver', function () {
findDependencies('templates/application.hbs', `{{#each things as |TheThing|}} <TheThing /> {{/each}}`)
).toEqual([]);
});
test('local binding takes precedence over modifier', function () {
let findDependencies = configure({ staticModifiers: true });
givenFile('modifiers/some-modifier.js');
expect(
findDependencies(
'templates/application.hbs',
`{{#each modifiers as |some-modifier|}} <div {{some-modifier}}></div> {{/each}}`
)
).toEqual([]);
});
test('angle components can establish local bindings', function () {
let findDependencies = configure({ staticHelpers: true });
givenFile('helpers/capitalize.js');
Expand All @@ -767,18 +830,26 @@ describe('compat-resolver', function () {
);
});
test('local binding only applies within block', function () {
let findDependencies = configure({ staticHelpers: true });
let findDependencies = configure({ staticHelpers: true, staticModifiers: true });
givenFile('helpers/capitalize.js');
givenFile('modifiers/validate.js');
expect(
findDependencies(
'templates/application.hbs',
`{{#each things as |capitalize|}} {{capitalize}} {{/each}} {{capitalize}}`
`
{{#each things as |capitalize|}} {{capitalize}} {{/each}} {{capitalize}}
<Form as |validate|><input {{validate}} /></Form> <input {{validate}} />
`
)
).toEqual([
{
runtimeName: 'the-app/helpers/capitalize',
path: '../helpers/capitalize.js',
},
{
runtimeName: 'the-app/modifiers/validate',
path: '../modifiers/validate.js',
},
]);
});
test('ignores builtins', function () {
Expand All @@ -792,10 +863,12 @@ describe('compat-resolver', function () {
{{#with (hash submit=(action doit)) as |thing| }}
{{/with}}
<LinkTo @route="index"/>
<form {{on "submit" doit}}></form>
`
)
).toEqual([]);
});

test('ignores dot-rule curly component invocation, inline', function () {
let findDependencies = configure({ staticHelpers: true, staticComponents: true });
expect(
Expand Down
Loading