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

[FEATURE] Implement on modifier. #17960

Merged
merged 2 commits into from
Apr 24, 2019
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
36 changes: 2 additions & 34 deletions packages/@ember/-internals/glimmer/lib/helpers/fn.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,11 @@
import { HAS_NATIVE_PROXY } from '@ember/-internals/utils';
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';
import { Arguments, VM } from '@glimmer/runtime';
import { ICapturedArguments } from '@glimmer/runtime/dist/types/lib/vm/arguments';
import { Opaque } from '@glimmer/util';
import { InternalHelperReference } from '../utils/references';
import buildUntouchableThis from '../utils/untouchable-this';

let context: any = null;
if (DEBUG && HAS_NATIVE_PROXY) {
let assertOnProperty = (property: string | number | symbol) => {
assert(
`You accessed \`this.${String(
property
)}\` from a function passed to the \`fn\` helper, but the function itself was not bound to a valid \`this\` context. Consider updating to usage of \`@action\`.`
);
};

context = new Proxy(
{},
{
get(_target: {}, property: string | symbol) {
assertOnProperty(property);
},

set(_target: {}, property: string | symbol) {
assertOnProperty(property);

return false;
},

has(_target: {}, property: string | symbol) {
assertOnProperty(property);

return false;
},
}
);
}

const context = buildUntouchableThis('`fn` helper');
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't touch this

Copy link
Contributor

Choose a reason for hiding this comment

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

yaaaaas

function fnHelper({ positional }: ICapturedArguments) {
assert(
`You must pass a function as the \`fn\` helpers first argument, you passed ${positional
Expand Down
262 changes: 262 additions & 0 deletions packages/@ember/-internals/glimmer/lib/modifiers/on.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';
import { Opaque, Simple } from '@glimmer/interfaces';
import { Tag } from '@glimmer/reference';
import { Arguments, CapturedArguments, ModifierManager } from '@glimmer/runtime';
import { Destroyable } from '@glimmer/util';
import buildUntouchableThis from '../utils/untouchable-this';

const untouchableContext = buildUntouchableThis('`on` modifier');

/*
Internet Explorer 11 does not support `once` and also does not support
passing `eventOptions`. In some situations it then throws a weird script
error, like:

```
Could not complete the operation due to error 80020101
```

This flag determines, whether `{ once: true }` and thus also event options in
general are supported.
*/
const SUPPORTS_EVENT_OPTIONS = (() => {
try {
const div = document.createElement('div');
let counter = 0;
div.addEventListener('click', () => counter++, { once: true });

let event;
if (typeof Event === 'function') {
event = new Event('click');
} else {
event = document.createEvent('Event');
event.initEvent('click', true, true);
}

div.dispatchEvent(event);
div.dispatchEvent(event);

return counter === 1;
} catch (error) {
return false;
}
})();

export class OnModifierState {
public tag: Tag;
public element: Element;
public args: CapturedArguments;
public eventName!: string;
public callback!: EventListener;
private userProvidedCallback!: EventListener;
public once?: boolean;
public passive?: boolean;
public capture?: boolean;
public options?: AddEventListenerOptions;
public shouldUpdate = true;

constructor(element: Element, args: CapturedArguments) {
this.element = element;
this.args = args;
this.tag = args.tag;
}

updateFromArgs() {
let { args } = this;

let { once, passive, capture }: AddEventListenerOptions = args.named.value();
Copy link
Contributor

Choose a reason for hiding this comment

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

In ember-on-modifier, we assert that only these three options are passed to guard against typos. Do you think we should add this here as well?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, the RFC specifically states the opposite (that passing other things is "ignored"):

From here:

The following named parameters will be accepted as options to {{on}}:

  • capture
  • once
  • passive

These will be passed forward to addEventListener as options in modern browsers, and will be polyfilled in older browsers. Other options will be ignored.

I don't feel super strongly so I just followed the RFC on this.

@pzuraq - Do you think we should change to assert?

if (once !== this.once) {
this.once = once;
this.shouldUpdate = true;
}

if (passive !== this.passive) {
this.passive = passive;
this.shouldUpdate = true;
}

if (capture !== this.capture) {
this.capture = capture;
this.shouldUpdate = true;
}

let options: AddEventListenerOptions;
if (once || passive || capture) {
options = this.options = { once, passive, capture };
} else {
this.options = undefined;
}

assert(
'You must pass a valid DOM event name as the first argument to the `on` modifier',
args.positional.at(0) !== undefined && typeof args.positional.at(0).value() === 'string'
rwjblue marked this conversation as resolved.
Show resolved Hide resolved
);
let eventName = args.positional.at(0).value() as string;
if (eventName !== this.eventName) {
this.eventName = eventName;
this.shouldUpdate = true;
}

assert(
'You must pass a function as the second argument to the `on` modifier',
args.positional.at(1) !== undefined && typeof args.positional.at(1).value() === 'function'
);
let userProvidedCallback = args.positional.at(1).value() as EventListener;
if (userProvidedCallback !== this.userProvidedCallback) {
this.userProvidedCallback = userProvidedCallback;
this.shouldUpdate = true;
}

assert(
`You can only pass two positional arguments (event name and callback) to the \`on\` modifier, but you provided ${
args.positional.length
}. Consider using the \`fn\` helper to provide additional arguments to the \`on\` callback.`,
args.positional.length === 2
);

let needsCustomCallback =
(SUPPORTS_EVENT_OPTIONS === false && once) /* needs manual once implementation */ ||
(DEBUG && passive) /* needs passive enforcement */;

if (this.shouldUpdate) {
if (needsCustomCallback) {
let callback = (this.callback = function(this: Element, event) {
if (DEBUG && passive) {
event.preventDefault = () => {
assert(
`You marked this listener as 'passive', meaning that you must not call 'event.preventDefault()': \n\n${userProvidedCallback}`
);
};
}

if (!SUPPORTS_EVENT_OPTIONS && once) {
removeEventListener(this, eventName, callback, options);
}
return userProvidedCallback.call(untouchableContext, event);
});
} else if (DEBUG) {
// prevent the callback from being bound to the element
this.callback = userProvidedCallback.bind(untouchableContext);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! 👍

} else {
this.callback = userProvidedCallback;
}
}
}

destroy() {
let { element, eventName, callback, options } = this;

removeEventListener(element, eventName, callback, options);
}
}

let adds = 0;
let removes = 0;

function removeEventListener(
element: Element,
eventName: string,
callback: EventListener,
options?: AddEventListenerOptions
): void {
removes++;

if (SUPPORTS_EVENT_OPTIONS) {
// when options are supported, use them across the board
element.removeEventListener(eventName, callback, options);
} else if (options !== undefined && options.capture) {
// used only in the following case:
//
// `{ once: true | false, passive: true | false, capture: true }
//
// `once` is handled via a custom callback that removes after first
// invocation so we only care about capture here as a boolean
element.removeEventListener(eventName, callback, true);
} else {
// used only in the following cases:
//
// * where there is no options
// * `{ once: true | false, passive: true | false, capture: false }
element.removeEventListener(eventName, callback);
}
}

function addEventListener(
element: Element,
eventName: string,
callback: EventListener,
options?: AddEventListenerOptions
): void {
adds++;
Copy link
Contributor

Choose a reason for hiding this comment

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

Assuming these aren't in DEBUGs because you wanted to run these tests against IE?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, exactly. We can ultimately remove if we care, but it's very very cheap and almost certainly won't matter.


if (SUPPORTS_EVENT_OPTIONS) {
// when options are supported, use them across the board
element.addEventListener(eventName, callback, options);
} else if (options !== undefined && options.capture) {
// used only in the following case:
//
// `{ once: true | false, passive: true | false, capture: true }
//
// `once` is handled via a custom callback that removes after first
// invocation so we only care about capture here as a boolean
element.addEventListener(eventName, callback, true);
} else {
// used only in the following cases:
//
// * where there is no options
// * `{ once: true | false, passive: true | false, capture: false }
element.addEventListener(eventName, callback);
}
}

export default class OnModifierManager implements ModifierManager<OnModifierState, Opaque> {
public SUPPORTS_EVENT_OPTIONS: boolean = SUPPORTS_EVENT_OPTIONS;

get counters() {
return { adds, removes };
}

create(element: Simple.Element | Element, _state: Opaque, args: Arguments) {
const capturedArgs = args.capture();

return new OnModifierState(<Element>element, capturedArgs);
}

getTag({ tag }: OnModifierState): Tag {
return tag;
}

install(state: OnModifierState) {
state.updateFromArgs();

let { element, eventName, callback, options } = state;

addEventListener(element, eventName, callback, options);

state.shouldUpdate = false;
}

update(state: OnModifierState) {
// stash prior state for el.removeEventListener
let { element, eventName, callback, options } = state;

state.updateFromArgs();

if (!state.shouldUpdate) {
return;
}

// use prior state values for removal
removeEventListener(element, eventName, callback, options);

// read updated values from the state object
addEventListener(state.element, state.eventName, state.callback, state.options);

state.shouldUpdate = false;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

If I'm reading this correctly, once the modifier is installed, it will only ever update to a new listener, but will never be uninstalled. I can't think of a scenario, where the modifier would need to be uninstalled from an element that does not get removed from the DOM afterwards, but I think @chancancode made a case for this once on Discord.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

@rwjblue rwjblue Apr 24, 2019

Choose a reason for hiding this comment

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

@buschtoens - We definitely cleanup (see getDestructor just below, and the destroy method on the OnModifierState object). I will also add a test (using the assert count methodology) to ensure we don't accidentally regress this.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I see! So getDestructor returns a Destroyable, which is implemented through destroy() in OnModifierState. I was not familiar with that part of the Glimmer API yet. 👍

Copy link
Member Author

Choose a reason for hiding this comment

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

Ya, this is one way the "internal" modifiers differ from the public API. The public API effectively does the same thing (the destroy method on the state bucket invokes the destroyModifier hook on the custom manager).


getDestructor(state: Destroyable) {
return state;
}
}
15 changes: 11 additions & 4 deletions packages/@ember/-internals/glimmer/lib/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { lookupComponent, lookupPartial, OwnedTemplateMeta } from '@ember/-inter
import {
EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS,
EMBER_GLIMMER_FN_HELPER,
EMBER_GLIMMER_ON_MODIFIER,
EMBER_MODULE_UNIFICATION,
} from '@ember/canary-features';
import { assert } from '@ember/debug';
Expand Down Expand Up @@ -44,6 +45,7 @@ import { default as readonly } from './helpers/readonly';
import { default as unbound } from './helpers/unbound';
import ActionModifierManager from './modifiers/action';
import { CustomModifierDefinition, ModifierManagerDelegate } from './modifiers/custom';
import OnModifierManager from './modifiers/on';
import { populateMacros } from './syntax';
import { mountHelper } from './syntax/mount';
import { outletHelper } from './syntax/outlet';
Expand Down Expand Up @@ -95,10 +97,17 @@ if (EMBER_GLIMMER_FN_HELPER) {
BUILTINS_HELPERS.fn = fn;
}

const BUILTIN_MODIFIERS = {
interface IBuiltInModifiers {
[name: string]: ModifierDefinition | undefined;
}
const BUILTIN_MODIFIERS: IBuiltInModifiers = {
action: { manager: new ActionModifierManager(), state: null },
on: undefined,
};

if (EMBER_GLIMMER_ON_MODIFIER) {
BUILTIN_MODIFIERS.on = { manager: new OnModifierManager(), state: null };
}
export default class RuntimeResolver implements IRuntimeResolver<OwnedTemplateMeta> {
public compiler: LazyCompiler<OwnedTemplateMeta>;

Expand All @@ -109,9 +118,7 @@ export default class RuntimeResolver implements IRuntimeResolver<OwnedTemplateMe

private builtInHelpers: IBuiltInHelpers = BUILTINS_HELPERS;

private builtInModifiers: {
[name: string]: ModifierDefinition;
} = BUILTIN_MODIFIERS;
private builtInModifiers: IBuiltInModifiers = BUILTIN_MODIFIERS;

// supports directly imported late bound layouts on component.prototype.layout
private templateCache: Map<Owner, Map<TemplateFactory, OwnedTemplate>> = new Map();
Expand Down
39 changes: 39 additions & 0 deletions packages/@ember/-internals/glimmer/lib/utils/untouchable-this.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { HAS_NATIVE_PROXY } from '@ember/-internals/utils';
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';

export default function buildUntouchableThis(source: string): null | object {
let context: null | object = null;
if (DEBUG && HAS_NATIVE_PROXY) {
let assertOnProperty = (property: string | number | symbol) => {
assert(
`You accessed \`this.${String(
property
)}\` from a function passed to the ${source}, but the function itself was not bound to a valid \`this\` context. Consider updating to usage of \`@action\`.`
);
};

context = new Proxy(
{},
{
get(_target: {}, property: string | symbol) {
assertOnProperty(property);
},

set(_target: {}, property: string | symbol) {
assertOnProperty(property);

return false;
},

has(_target: {}, property: string | symbol) {
assertOnProperty(property);

return false;
},
}
);
}

return context;
}
Loading