Skip to content

Commit

Permalink
Implement this-fallback-plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
gitKrystan committed Apr 19, 2023
1 parent 1dbb355 commit 03a6aa2
Show file tree
Hide file tree
Showing 40 changed files with 3,393 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ module.exports = {
'./blueprints/*/index.js',
'./config/**/*.js',
'./tests/dummy/config/**/*.js',
// In addition to what ember-cli adds above:
'./jest.config.js',
],
env: {
browser: false,
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.

ember-this-fallback-plugin.log

# compiled output
/dist/
/tmp/
Expand Down
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
ember-this-fallback-plugin.log

# compiled output
/dist/
/tmp/
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
ember-this-fallback-plugin.log

# unconventional js
/blueprints/*/files/
/vendor/
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@
- Visit the dummy application at [http://localhost:4200](http://localhost:4200).

For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/).

## Debugging

This plugin uses [the `debug` library](https://www.npmjs.com/package/debug) to log messages. You can see this output by setting the `DEBUG` environment variable to `*` or `ember-this-fallback-plugin`. Typically, environment variables specific to a command are set by prefixing the command. For example:

```shell
DEBUG=ember-this-fallback-plugin yarn start
```
100 changes: 96 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ember-this-fallback

[Short description of the addon.]
Polyfills [Ember's deprecated Property Fallback Lookup feature](https://deprecations.emberjs.com/v3.x/#toc_this-property-fallback) to allow apps to continue using it without blocking Ember 4.0+ upgrades.

## Compatibility

Expand All @@ -10,13 +10,105 @@

## Installation

```
```shell
ember install ember-this-fallback
```

## Usage
## How It Works

The addon registers an [ember-cli-htmlbars plugin](https://github.com/ember-cli/ember-cli-htmlbars#adding-custom-plugins) that traverses the nodes in your Ember templates and transforms them using the following logic:

For each `PathExpression` with a `VarHead` that is NOT in the local template scope:

- If it is within `node.params` or a `node.hash` value for a `CallNode` (`MustacheStatement | BlockStatement | ElementModifierStatement | SubExpression`):

Prefix the `head` with `this`, making it a `ThisHead` ("expression fallback").

For example:

```hbs
{{! before }}
{{global-helper property}}
{{! after }}
{{global-helper this.property}}
```

- If is the `path` for a `MustacheStatement` with NO params or hash:

- If there is a `tail`:

Prefix the `head` with `this`, making it a `ThisHead` ("expression fallback").

```hbs
{{! before }}
{{property.value}}
{{! after }}
{{this.property.value}}
```

- If there is NO `tail`:

- If the `MustacheStatement` is the child of an `AttrNode` with a name NOT starting with `@`:

Wrap the invocation with the `this-fallback/is-helper` helper to determine if it is a helper at runtime and fallback to the `this` property if not ("ambiguous attribute fallback").

```hbs
{{! before }}
<Parent id={{property}} />
{{! after }}
<Parent
id={{(if
(this-fallback/is-helper "property")
(helper (this-fallback/lookup-helper "property"))
this.property
)}}
/>
```

- Otherwise:

Wrap the invocation with the `this-fallback/is-invocable` helper to determine if it is a component or helper at runtime and fallback to the `this` property if not ("ambiguous statement fallback").

```hbs
{{! before }}
{{property}}
{{! after }}
{{#if (this-fallback/is-invocable "property")}}
{{property}}
{{else}}
{{this.property}}
{{/if}}
```

### Caveats

#### Runtime implications

The `this-fallback/is-invokable`, `this-fallback/is-helper`, and `this-fallback/lookup-helper` helpers have runtime implications that may have performance impacts. Thus, we recommend relying on this addon only temporarily to unblock 4.0+ upgrades while continuing to migrate away from reliance on the Property Fallback Lookup feature.

#### Embroider compatibility

In the "ambiguous attribute fallback" case shown above, we fall back to dynamic resolution at runtime to determine if the contents of the mustache statement point to a helper or a property on `this`. This technique is fundamentally incompatible with [Embroider](https://github.com/embroider-build/embroider) strict mode, specifically the `staticHelpers` config (and thus, `splitAtRoutes`), which requires that helpers are resolvable at build-time.

Thus, in these cases, we log a warning to `ember-this-fallback-plugin.log`. If you wish to use Embroider's `staticHelpers` config, we recommend manually updating the code in these cases to either:

```hbs
{{! For a known property on `this`. }}
<Parent id={{this.property}} />
```

or

```hbs
{{! For a known global helper. }}
<Parent id={{(property)}} />
```

[Longer description of how to use the addon in apps.]
In the future, we could resolve this incompatibility if we had access to Embroider's static resolution.

## Contributing

Expand Down
12 changes: 12 additions & 0 deletions addon/get-owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getOwner as deprecatedGetOwner } from '@ember/application';
import type Owner from '@ember/owner';

/**
* Re-export of the `getOwner` export from `@ember/application`.
* This export is deprecated as of
* [Ember 4.10](https://github.com/emberjs/ember.js/releases/tag/v4.10.0)
* but we need to support Ember versions prior to 4.10 also.
*/
export function getOwner(object: object): Owner | undefined {
return deprecatedGetOwner(object);
}
26 changes: 26 additions & 0 deletions addon/helpers/this-fallback/is-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Helper from '@ember/component/helper';
import { getOwner } from '../../get-owner';
import { assertExists } from '../../types/util';

type Positional = [name: string];

interface IsHelperSignature {
Args: {
Positional: Positional;
};
Return: boolean;
}

export default class IsHelper extends Helper<IsHelperSignature> {
compute([name]: Positional): boolean {
const owner = assertExists(getOwner(this), 'Could not find owner');
return Boolean(owner.factoryFor(`helper:${name}`));
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
/** Checks if a helper with the provided name exists. */
'this-fallback/is-helper': typeof IsHelper;
}
}
29 changes: 29 additions & 0 deletions addon/helpers/this-fallback/is-invocable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Helper from '@ember/component/helper';
import { getOwner } from '../../get-owner';
import { assertExists } from '../../types/util';

type Positional = [name: string];

interface IsInvocableSignature {
Args: {
Positional: Positional;
};
Return: boolean;
}

export default class IsInvocable extends Helper<IsInvocableSignature> {
compute([name]: Positional): boolean {
const owner = assertExists(getOwner(this), 'Could not find owner');
return (
Boolean(owner.factoryFor(`component:${name}`)) ||
Boolean(owner.factoryFor(`helper:${name}`))
);
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
/** Checks if a component or helper with the provided name exists. */
'this-fallback/is-invocable': typeof IsInvocable;
}
}
34 changes: 34 additions & 0 deletions addon/helpers/this-fallback/lookup-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Helper from '@ember/component/helper';
import { assert } from '@ember/debug';
import { type HelperLike } from '@glint/template';
import { getOwner } from '../../get-owner';
import { assertExists } from '../../types/util';

type Positional = [name: string];

interface LookupHelperSignature {
Args: {
Positional: Positional;
};
Return: HelperLike;
}

export default class LookupHelper extends Helper<LookupHelperSignature> {
compute([name]: Positional): HelperLike {
const owner = assertExists(getOwner(this), 'Could not find owner');
const helper = owner.lookup(`helper:${name}`);
assert(`Expected to find helper with name ${name}`, helper);
return helper as HelperLike;
}
}

declare module '@glint/environment-ember-loose/registry' {
export default interface Registry {
/**
* Returns the helper with the provided name. Asserts it's existence.
* Similar to `helper` helper, but avoids build-time errors for
* this-fallback invocations.
*/
'this-fallback/lookup-helper': typeof LookupHelper;
}
}
10 changes: 10 additions & 0 deletions addon/types/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { assert } from '@ember/debug';

/** Asserts that the given value is not undefined. */
export function assertExists<T>(
value: T | undefined,
message = 'assertExists failed'
): T {
assert(message, value !== undefined);
return value;
}
1 change: 1 addition & 0 deletions app/helpers/this-fallback/is-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-this-fallback/helpers/this-fallback/is-helper';
1 change: 1 addition & 0 deletions app/helpers/this-fallback/is-invocable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-this-fallback/helpers/this-fallback/is-invocable';
1 change: 1 addition & 0 deletions app/helpers/this-fallback/lookup-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from 'ember-this-fallback/helpers/this-fallback/lookup-helper';
1 change: 1 addition & 0 deletions ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = function (defaults) {

const { maybeEmbroider } = require('@embroider/test-setup');
return maybeEmbroider(app, {
staticHelpers: false,
skipBabel: [
{
package: 'qunit',
Expand Down
17 changes: 11 additions & 6 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
'use strict';

var HelloTransform = require('./lib/hello');
var ThisFallbackPlugin = require('./lib/this-fallback-plugin');

module.exports = {
name: require('./package').name,

included: function () {
// we have to wrap these in an object so the ember-cli
// registry doesn't try to call `new` on them (new is actually
// called within htmlbars when compiling a given template).
const plugin = ThisFallbackPlugin;
plugin.baseDir = () => {
return __dirname;
};
plugin.cacheKey = () => {
return 'ember-this-fallback';
};

this.app.registry.add('htmlbars-ast-plugin', {
name: 'hello-transform',
plugin: HelloTransform,
name: 'ember-this-fallback',
plugin,
});

Reflect.apply(this._super.included, this, arguments);
Expand Down
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
modulePathIgnorePatterns: ['<rootDir>/.node_modules'],
};
23 changes: 0 additions & 23 deletions lib/hello.ts

This file was deleted.

44 changes: 44 additions & 0 deletions lib/helpers/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { type AST, type WalkerPath } from '@glimmer/syntax';

/**
* Replaces the given node with the given replacement.
*
* @param node The child node to replace
* @param path The path containing the parentNode within which we will replace
* the child
* @param replacement The Statement node with which to replace the child node
*/
export function replaceChild<N extends AST.Statement>(
node: N,
path: WalkerPath<N>,
replacement: AST.Statement
): void {
const parent = path.parentNode;
if (parent) {
const parentChildren = getChildren(parent);
const index = parentChildren.indexOf(node);
if (index === -1) {
throw new Error('could not find given node in parent children');
}
parentChildren.splice(index, 1, replacement);
} else {
throw new Error('expected node to have a parent node');
}
}

function getChildren(node: AST.Node): AST.Statement[] {
switch (node.type) {
case 'Block':
case 'Template': {
return node.body;
}
case 'ElementNode': {
return node.children;
}
default: {
throw new Error(
`could not find children for node with type ${node.type}`
);
}
}
}
Loading

0 comments on commit 03a6aa2

Please sign in to comment.