diff --git a/.eslintrc.js b/.eslintrc.js index 898a59cd2..295aceb04 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -44,7 +44,11 @@ module.exports = { }, // source Js { - files: ['src/**/*.js'], + files: ['**/src/**/*.js', '**/test/**/*.js'], + env: { + es2020: true, + browser: true, + }, parserOptions: { sourceType: 'module', }, diff --git a/.vscode/settings.json b/.vscode/settings.json index 6124d4042..f924736e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "**/bower_components": true, "dist": true }, - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "eslint.enable": true } \ No newline at end of file diff --git a/packages/@glimmer/core/index.ts b/packages/@glimmer/core/index.ts index a179eb5c1..e99938d5f 100644 --- a/packages/@glimmer/core/index.ts +++ b/packages/@glimmer/core/index.ts @@ -13,7 +13,14 @@ export { setModifierManager, } from './src/managers'; -export { Args as CapturedArgs } from './src/interfaces'; +export { Args } from './src/interfaces'; + +export { + ModifierManager, + ModifierDefinition, + capabilities as modifierCapabilities, + Capabilities as ModifierCapabilities, +} from './src/managers/modifier'; export { HelperManager, diff --git a/packages/@glimmer/core/src/managers/index.ts b/packages/@glimmer/core/src/managers/index.ts index 8b5aeb827..ccddae174 100644 --- a/packages/@glimmer/core/src/managers/index.ts +++ b/packages/@glimmer/core/src/managers/index.ts @@ -93,7 +93,7 @@ function getManagerInstanceForOwner( /////////// export function setModifierManager( - factory: ManagerFactory>, + factory: ManagerFactory>, definition: ModifierDefinition ): {} { return setManager({ factory, type: 'modifier' }, definition); @@ -102,7 +102,7 @@ export function setModifierManager( export function getModifierManager( owner: object, definition: ModifierDefinition -): ModifierManager | undefined { +): ModifierManager | undefined { const wrapper = getManager(definition); if (wrapper !== undefined && wrapper.type === 'modifier') { diff --git a/packages/@glimmer/core/src/managers/modifier.ts b/packages/@glimmer/core/src/managers/modifier.ts index cca255656..8acf4fd69 100644 --- a/packages/@glimmer/core/src/managers/modifier.ts +++ b/packages/@glimmer/core/src/managers/modifier.ts @@ -1,6 +1,20 @@ -import { assert } from '@glimmer/util'; +import { DEBUG } from '@glimmer/env'; +import { + ModifierManager as VMModifierManager, + VMArguments, + CapturedArguments, + Destroyable, + DynamicScope, +} from '@glimmer/interfaces'; +import { Tag, createUpdatableTag, track, untrack, combine, update } from '@glimmer/validator'; +import { assert, debugToString } from '@glimmer/util'; import { SimpleElement } from '@simple-dom/interface'; import { Args } from '../interfaces'; +import debugRenderMessage from '../utils/debug'; +import { argsProxyFor } from './util'; +import { getModifierManager } from '.'; +import { OWNER_KEY } from '../owner'; +import { VMModifierDefinitionWithHandle } from '../render-component/vm-definitions'; /////////// @@ -10,7 +24,7 @@ export interface Capabilities { export type OptionalCapabilities = Partial; -export type ManagerAPIVersion = '3.4' | '3.13'; +export type ManagerAPIVersion = '3.13'; export function capabilities( managerAPI: ManagerAPIVersion, @@ -25,12 +39,152 @@ export function capabilities( /////////// -export interface ModifierManager { +export interface ModifierManager { capabilities: Capabilities; - createModifier(definition: unknown, args: Args): ModifierInstance; - installModifier(instance: ModifierInstance, element: SimpleElement, args: Args): void; - updateModifier(instance: ModifierInstance, args: Args): void; - destroyModifier(instance: ModifierInstance, args: Args): void; + createModifier(definition: unknown, args: Args): ModifierStateBucket; + installModifier(instance: ModifierStateBucket, element: Element, args: Args): void; + updateModifier(instance: ModifierStateBucket, args: Args): void; + destroyModifier(instance: ModifierStateBucket, args: Args): void; } export type ModifierDefinition<_Instance = unknown> = {}; + +export type SimpleModifier = (element: Element, ...args: unknown[]) => undefined | (() => void); + +interface SimpleModifierStateBucket { + definition: SimpleModifier; + destructor?(): void; + element?: Element; +} + +class SimpleModifierManager implements ModifierManager { + capabilities = capabilities('3.13'); + + createModifier(definition: SimpleModifier, args: Args): SimpleModifierStateBucket { + if (DEBUG) { + assert(Object.keys(args.named).length === 0, `You used named arguments with the ${definition.name} modifier, but it is a standard function. Normal functions cannot receive named arguments when used as modifiers.`); + } + + return { definition }; + } + + installModifier(bucket: SimpleModifierStateBucket, element: Element, args: Args): void { + bucket.destructor = bucket.definition(element, ...args.positional); + bucket.element = element; + } + + updateModifier(bucket: SimpleModifierStateBucket, args: Args): void { + this.destroyModifier(bucket); + this.installModifier(bucket, bucket.element!, args); + } + + destroyModifier(bucket: SimpleModifierStateBucket): void { + const { destructor } = bucket; + + if (destructor !== undefined) { + destructor(); + } + } +} + +const SIMPLE_MODIFIER_MANAGER = new SimpleModifierManager(); + +/////////// + +export class CustomModifierState { + public tag = createUpdatableTag(); + + constructor( + public element: SimpleElement, + public delegate: ModifierManager, + public modifier: ModifierStateBucket, + public argsProxy: Args, + public capturedArgs: CapturedArguments + ) {} + + destroy(): void { + const { delegate, modifier, argsProxy } = this; + delegate.destroyModifier(modifier, argsProxy); + } +} + +export class CustomModifierManager + implements + VMModifierManager< + CustomModifierState, + ModifierDefinition + > { + create( + element: SimpleElement, + definition: ModifierDefinition, + args: VMArguments, + dynamicScope: DynamicScope + ): CustomModifierState { + const owner = dynamicScope.get(OWNER_KEY).value() as object; + let delegate = getModifierManager(owner, definition); + + if (delegate === undefined) { + if (DEBUG) { + assert( + typeof definition === 'function', + `No modifier manager found for ${definition}, and it was not a plain function, so it could not be used as a modifier` + ); + } + + delegate = (SIMPLE_MODIFIER_MANAGER as unknown) as ModifierManager; + } + + const capturedArgs = args.capture(); + const argsProxy = argsProxyFor(capturedArgs, 'modifier'); + + const instance = delegate.createModifier(definition, argsProxy); + + return new CustomModifierState(element, delegate, instance, argsProxy, capturedArgs); + } + + getTag({ tag, capturedArgs }: CustomModifierState): Tag { + return combine([tag, capturedArgs.tag]); + } + + install(state: CustomModifierState): void { + const { element, argsProxy, delegate, modifier, tag } = state; + + if (delegate.capabilities.disableAutoTracking === true) { + untrack(() => delegate.installModifier(modifier, element as Element, argsProxy)); + } else { + const combinedTrackingTag = track( + () => delegate.installModifier(modifier, element as Element, argsProxy), + DEBUG && debugRenderMessage!(`(instance of a \`${debugToString!(modifier)}\` modifier)`) + ); + + update(tag, combinedTrackingTag); + } + } + + update(state: CustomModifierState): void { + const { argsProxy, delegate, modifier, tag } = state; + + if (delegate.capabilities.disableAutoTracking === true) { + untrack(() => delegate.updateModifier(modifier, argsProxy)); + } else { + const combinedTrackingTag = track( + () => delegate.updateModifier(modifier, argsProxy), + DEBUG && debugRenderMessage!(`(instance of a \`${debugToString!(modifier)}\` modifier)`) + ); + update(tag, combinedTrackingTag); + } + } + + getDestructor(state: CustomModifierState): Destroyable { + return state; + } +} + +export const CUSTOM_MODIFIER_MANAGER = new CustomModifierManager(); + +export class VMCustomModifierDefinition + implements VMModifierDefinitionWithHandle { + public manager = CUSTOM_MODIFIER_MANAGER; + + constructor(public handle: number, public state: ModifierDefinition) {} +} diff --git a/packages/@glimmer/core/src/render-component/index.ts b/packages/@glimmer/core/src/render-component/index.ts index d6ef9fc22..aa5b9a643 100644 --- a/packages/@glimmer/core/src/render-component/index.ts +++ b/packages/@glimmer/core/src/render-component/index.ts @@ -116,8 +116,10 @@ const context = JitContext(new CompileTimeResolver(resolver)); const program = new RuntimeProgramImpl(context.program.constants, context.program.heap); function dictToReference(dict: Dict, env: Environment): Dict { + const root = new ComponentRootReference(dict, env); + return Object.keys(dict).reduce((acc, key) => { - acc[key] = new ComponentRootReference(dict[key], env); + acc[key] = root.get(key); return acc; }, {} as Dict); } diff --git a/packages/@glimmer/core/src/render-component/resolvers.ts b/packages/@glimmer/core/src/render-component/resolvers.ts index 375e6708e..37eed10e8 100644 --- a/packages/@glimmer/core/src/render-component/resolvers.ts +++ b/packages/@glimmer/core/src/render-component/resolvers.ts @@ -11,9 +11,8 @@ import { ResolverDelegate, unwrapTemplate } from '@glimmer/opcode-compiler'; import { vmDefinitionForComponent, vmDefinitionForHelper, + vmDefinitionForModifier, vmDefinitionForBuiltInHelper, - Modifier, - vmHandleForModifier, VMHelperDefinition, } from './vm-definitions'; @@ -22,6 +21,7 @@ import { TemplateMeta } from '../template'; import { HelperDefinition } from '../managers/helper'; import { ifHelper } from './built-ins'; import { DEBUG } from '@glimmer/env'; +import { ModifierDefinition } from '../managers/modifier'; const builtInHelpers: { [key: string]: VMHelperDefinition } = { if: vmDefinitionForBuiltInHelper(ifHelper), @@ -92,10 +92,13 @@ export class CompileTimeResolver implements ResolverDelegate { lookupModifier(name: string, referrer: TemplateMeta): Option { const scope = referrer.scope(); - const modifier = scope[name] as Modifier; + const modifier = scope[name] as ModifierDefinition; + + const definition = vmDefinitionForModifier(modifier); + const { handle } = definition; + + this.inner.registry[handle] = definition; - const handle = vmHandleForModifier(modifier); - this.inner.registry[handle] = modifier; return handle; } diff --git a/packages/@glimmer/core/src/render-component/vm-definitions.ts b/packages/@glimmer/core/src/render-component/vm-definitions.ts index 7cb079ed3..160bfb1c3 100644 --- a/packages/@glimmer/core/src/render-component/vm-definitions.ts +++ b/packages/@glimmer/core/src/render-component/vm-definitions.ts @@ -1,5 +1,6 @@ import { ComponentDefinition as VMComponentDefinition, + ModifierDefinition as VMModifierDefinition, Helper as VMHelperFactory, ModifierManager, TemplateOk, @@ -14,12 +15,18 @@ import { TemplateOnlyComponent, } from '../managers/component/template-only'; import { DEBUG } from '@glimmer/env'; +import { ModifierDefinition, VMCustomModifierDefinition } from '../managers/modifier'; export interface VMComponentDefinitionWithHandle extends VMComponentDefinition { handle: number; template: TemplateOk; } +export interface VMModifierDefinitionWithHandle extends VMModifierDefinition { + handle: number; +} + + export interface VMHelperDefinition { helper: VMHelperFactory; handle: number; @@ -36,7 +43,7 @@ let HANDLE = 0; const VM_COMPONENT_DEFINITIONS = new WeakMap(); const VM_HELPER_DEFINITIONS = new WeakMap(); -const VM_MODIFIER_HANDLES = new WeakMap(); +const VM_MODIFIER_DEFINITIONS = new WeakMap(); export function vmDefinitionForComponent( ComponentDefinition: ComponentDefinition @@ -48,15 +55,8 @@ export function vmDefinitionForHelper(Helper: HelperDefinition): VMHelperDefinit return VM_HELPER_DEFINITIONS.get(Helper) || createVMHelperDefinition(Helper); } -export function vmHandleForModifier(modifier: Modifier): number { - let handle = VM_MODIFIER_HANDLES.get(modifier); - - if (!handle) { - handle = HANDLE++; - VM_MODIFIER_HANDLES.set(modifier, handle); - } - - return handle; +export function vmDefinitionForModifier(Modifier: ModifierDefinition): VMModifierDefinitionWithHandle { + return VM_MODIFIER_DEFINITIONS.get(Modifier) || createVMModifierDefinition(Modifier); } /////////// @@ -115,3 +115,13 @@ function createVMHelperDefinition(userDefinition: HelperDefinition): VMHelperDef VM_HELPER_DEFINITIONS.set(userDefinition, definition); return definition; } + +function createVMModifierDefinition( + Modifier: ModifierDefinition +): VMModifierDefinitionWithHandle { + const definition = new VMCustomModifierDefinition(HANDLE++, Modifier); + + VM_MODIFIER_DEFINITIONS.set(Modifier, definition); + + return definition; +} diff --git a/packages/@glimmer/core/src/utils/debug.ts b/packages/@glimmer/core/src/utils/debug.ts new file mode 100644 index 000000000..fba03be5d --- /dev/null +++ b/packages/@glimmer/core/src/utils/debug.ts @@ -0,0 +1,11 @@ +import { DEBUG } from '@glimmer/env'; + +let debugRenderMessage: undefined | ((renderingStack: string) => string); + +if (DEBUG) { + debugRenderMessage = (renderingStack: string): string => { + return `While rendering:\n----------------\n${renderingStack.replace(/^/gm, ' ')}`; + }; +} + +export default debugRenderMessage; diff --git a/packages/@glimmer/core/test/interactive/modifier-test.ts b/packages/@glimmer/core/test/interactive/modifier-test.ts index 249fa6bd6..291133274 100644 --- a/packages/@glimmer/core/test/interactive/modifier-test.ts +++ b/packages/@glimmer/core/test/interactive/modifier-test.ts @@ -1,9 +1,55 @@ -import { module, test, render, settled } from '../utils'; +import { module, test, render, settled, tracked } from '../utils'; +import { click, find } from '../utils/dom'; import { on, action } from '@glimmer/modifier'; import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { setComponentTemplate, createTemplate } from '@glimmer/core'; +import { + setComponentTemplate, + createTemplate, + templateOnlyComponent, + modifierCapabilities, + Args, + setModifierManager, + ModifierManager, +} from '@glimmer/core'; +import { Dict } from '@glimmer/interfaces'; + +class CustomModifier { + element?: Element; + // eslint-disable-next-line @typescript-eslint/no-empty-function + didInsertElement(_positional: unknown[], _named: Dict): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + didUpdate(_positional: unknown[], _named: Dict): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + willDestroyElement(): void {} +} + +class CustomModifierManager implements ModifierManager { + capabilities = modifierCapabilities('3.13'); + + constructor(private owner: unknown) {} + + createModifier(factory: { new (owner: unknown): CustomModifier }): CustomModifier { + return new factory(this.owner); + } + + installModifier(instance: CustomModifier, element: Element, args: Args): void { + instance.element = element; + const { positional, named } = args; + instance.didInsertElement(positional, named); + } + + updateModifier(instance: CustomModifier, args: Args): void { + const { positional, named } = args; + instance.didUpdate(positional, named); + } + + destroyModifier(instance: CustomModifier): void { + instance.willDestroyElement(); + } +} + +setModifierManager(owner => new CustomModifierManager(owner), CustomModifier); module('Modifier Tests', () => { test('Supports the on modifier', async assert => { @@ -24,23 +70,276 @@ module('Modifier Tests', () => { ) ); - const element = document.getElementById('qunit-fixture')!; - - await render(MyComponent, element); assert.strictEqual( - element.innerHTML, + await render(MyComponent), ``, 'the component was rendered' ); - const button = element.querySelector('button')!; - button.click(); + click('button'); - await settled(); assert.strictEqual( - element.innerHTML, + await settled(), ``, 'the component was rerendered' ); }); + + test('simple functions can be used as modifiers', async assert => { + function modifier(element: Element, arg1: string, arg2: number): void { + assert.equal(element, find('h1'), 'modifier received'); + assert.equal(arg1, 'string', 'modifier received'); + assert.equal(arg2, 123, 'modifier received'); + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate({ modifier }, '

hello world

') + ); + + await render(Component); + }); + + test('simple function modifiers throw an error when using named arguments', async assert => { + function modifier(): void { + assert.ok(false, 'should not be called'); + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate({ modifier }, '

hello world

') + ); + + try { + await render(Component); + } catch (e) { + assert.equal(e.message, 'You used named arguments with the modifier modifier, but it is a standard function. Normal functions cannot receive named arguments when used as modifiers.', 'error thrown correctly'); + } + }); + + + test('simple function modifier lifecycle', async assert => { + const hooks: string[] = []; + + function modifier(): () => void { + hooks.push('installed'); + + return (): void => { + hooks.push('removed'); + }; + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate({ modifier }, '{{#if @truthy}}

hello world

{{/if}}') + ); + + await render(Component); + + const args = tracked({ + truthy: true, + value: 123, + }); + + await render(Component, { args }); + + assert.deepEqual(hooks, ['installed'], 'installs correctly'); + + // trigger update + args.value = 456; + await settled(); + + assert.deepEqual( + hooks, + ['installed', 'removed', 'installed'], + 'removes and reinstalls on updates' + ); + + // trigger destruction + args.truthy = false; + await settled(); + + assert.deepEqual( + hooks, + ['installed', 'removed', 'installed', 'removed'], + 'removes on final destruction' + ); + }); + + test('custom modifiers correctly receive element', async assert => { + assert.expect(3); + + class Modifier extends CustomModifier { + didInsertElement(positional: unknown[]): void { + positional[0]; + assert.equal(this.element, find('h1'), 'element is correctly assigned in didInsertElement'); + } + + didUpdate(): void { + assert.equal(this.element, find('h1'), 'element still exists in didUpdate'); + } + + willDestroyElement(): void { + assert.ok( + this.element instanceof HTMLElement, + 'element still exists in willDestroyElement' + ); + } + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate( + { modifier: Modifier }, + '{{#if @truthy}}

hello world

{{/if}}' + ) + ); + + const args = tracked({ + truthy: true, + value: 123, + }); + + await render(Component, { args }); + + // trigger update + args.value = 456; + await settled(); + + // trigger destruction + args.truthy = false; + await settled(); + }); + + test('custom lifecycle hooks', async assert => { + const hooks: string[] = []; + const positionalArgs: unknown[][] = []; + const namedArgs: Dict[] = []; + + class Modifier extends CustomModifier { + didInsertElement(positional: unknown[], named: Dict): void { + hooks.push('didInsertElement'); + positionalArgs.push(positional.slice()); + namedArgs.push(Object.assign({}, named)); + } + + didUpdate(positional: unknown[], named: Dict): void { + hooks.push('didUpdate'); + positionalArgs.push(positional.slice()); + namedArgs.push(Object.assign({}, named)); + } + + willDestroyElement(): void { + hooks.push('willDestroyElement'); + } + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate( + { modifier: Modifier }, + '{{#if @truthy}}

hello world

{{/if}}' + ) + ); + + const args = tracked({ + truthy: true, + value: 123, + }); + + await render(Component, { args }); + + assert.deepEqual(hooks, ['didInsertElement'], 'modifier initialized correctly'); + assert.deepEqual(positionalArgs, [[123]], 'modifier initialized correctly'); + assert.deepEqual(namedArgs, [{ foo: 123 }], 'modifier initialized correctly'); + + args.value = 456; + await settled(); + + assert.deepEqual(hooks, ['didInsertElement', 'didUpdate'], 'modifier initialized correctly'); + assert.deepEqual(positionalArgs, [[123], [456]], 'modifier initialized correctly'); + assert.deepEqual(namedArgs, [{ foo: 123 }, { foo: 456 }], 'modifier initialized correctly'); + + args.truthy = false; + await settled(); + + assert.deepEqual( + hooks, + ['didInsertElement', 'didUpdate', 'willDestroyElement'], + 'modifier initialized correctly' + ); + assert.deepEqual(positionalArgs, [[123], [456]], 'modifier initialized correctly'); + assert.deepEqual(namedArgs, [{ foo: 123 }, { foo: 456 }], 'modifier initialized correctly'); + }); + + test('lifecycle hooks are autotracked by default', async assert => { + const obj = tracked({ + foo: 123, + bar: 456, + }); + + const hooks: string[] = []; + + class Modifier extends CustomModifier { + didInsertElement(): void { + // read and entangle + obj.foo; + hooks.push('insert'); + } + + didUpdate(): void { + // read and entangle + obj.bar; + hooks.push('update'); + } + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate({ modifier: Modifier }, '

hello world

') + ); + + const html = await render(Component); + assert.equal(html, `

hello world

`, 'rendered correctly'); + + assert.deepEqual(hooks, ['insert'], 'correct hooks called on initial render'); + + obj.bar++; + await settled(); + + assert.deepEqual(hooks, ['insert'], 'update not called when unconsumed prop is updated'); + + obj.foo++; + await settled(); + + assert.deepEqual( + hooks, + ['insert', 'update'], + 'update called when prop consumed in prop is updated' + ); + + obj.foo++; + await settled(); + + assert.deepEqual( + hooks, + ['insert', 'update'], + 'update not called when unconsumed prop is updated' + ); + + obj.bar++; + await settled(); + + assert.deepEqual( + hooks, + ['insert', 'update', 'update'], + 'update called when prop consumed in prop is updated' + ); + }); }); diff --git a/packages/@glimmer/core/test/utils.ts b/packages/@glimmer/core/test/utils.ts index 252273630..4276c1681 100644 --- a/packages/@glimmer/core/test/utils.ts +++ b/packages/@glimmer/core/test/utils.ts @@ -9,6 +9,9 @@ import { import { renderToString } from '@glimmer/ssr'; import { SerializedTemplateWithLazyBlock } from '@glimmer/interfaces'; import { TemplateMeta } from '../src/template'; +import { tracked as glimmerTracked } from '@glimmer/tracking'; + +import TrackedObject from './utils/tracked-object'; export const module = QUnit.module; export const test = QUnit.test; @@ -57,3 +60,31 @@ export async function settled(): Promise { return document.getElementById('qunit-fixture')!.innerHTML; } + +export function tracked( + obj: T | typeof Object +): T; + +export function tracked( + obj: object, + key: string | symbol, + desc?: PropertyDescriptor +): void; + +export function tracked( + obj: object, + key?: string | symbol, + desc?: PropertyDescriptor +): object | void { + if (key !== undefined && desc !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (glimmerTracked as any)(obj, key as string, desc); + } + + switch (obj) { + case Object: + return new TrackedObject(); + } + + return new TrackedObject(obj); +} diff --git a/packages/@glimmer/core/test/utils/dom.ts b/packages/@glimmer/core/test/utils/dom.ts new file mode 100644 index 000000000..f903f8293 --- /dev/null +++ b/packages/@glimmer/core/test/utils/dom.ts @@ -0,0 +1,11 @@ +export function find(selector: string): Element { + const element = document.getElementById('qunit-fixture')!; + return element.querySelector(selector)!; +} + +export function click(selector: string): void { + const element = document.getElementById('qunit-fixture')!; + const clickable = element.querySelector(selector)! as HTMLButtonElement; + + clickable.click(); +} diff --git a/packages/@glimmer/core/test/utils/tracked-object.d.ts b/packages/@glimmer/core/test/utils/tracked-object.d.ts new file mode 100644 index 000000000..4d21a90e8 --- /dev/null +++ b/packages/@glimmer/core/test/utils/tracked-object.d.ts @@ -0,0 +1,9 @@ +declare interface TrackedObject { + fromEntries(entries: Iterable): { [k in PropertyKey]: T } + + new(obj?: T): T; +} + +declare const TrackedObject: TrackedObject; + +export default TrackedObject; diff --git a/packages/@glimmer/core/test/utils/tracked-object.js b/packages/@glimmer/core/test/utils/tracked-object.js new file mode 100644 index 000000000..af646d1c5 --- /dev/null +++ b/packages/@glimmer/core/test/utils/tracked-object.js @@ -0,0 +1,58 @@ +import { consume, tagFor, dirtyTagFor } from '@glimmer/validator'; + +const COLLECTION = Symbol(); + +function createProxy(obj = {}) { + + return new Proxy(obj, { + get(target, prop) { + consume(tagFor(target, prop)); + + return target[prop]; + }, + + has(target, prop) { + consume(tagFor(target, prop)); + + return prop in target; + }, + + ownKeys(target) { + consume(tagFor(target, COLLECTION)); + + return Reflect.ownKeys(target); + }, + + set(target, prop, value) { + target[prop] = value; + + dirtyTagFor(target, prop); + dirtyTagFor(target, COLLECTION); + + return true; + }, + + getPrototypeOf() { + return TrackedObject.prototype; + }, + }); +} + +export default class TrackedObject { + static fromEntries(entries) { + return createProxy(Object.fromEntries(entries)); + } + + constructor(obj = {}) { + let proto = Object.getPrototypeOf(obj); + let descs = Object.getOwnPropertyDescriptors(obj) + + let clone = Object.create(proto); + + for (let prop in descs) { + Object.defineProperty(clone, prop, descs[prop]); + } + + return createProxy(clone); + } +} diff --git a/packages/@glimmer/modifier/src/on.ts b/packages/@glimmer/modifier/src/on.ts index 351332e8d..fef9236ce 100644 --- a/packages/@glimmer/modifier/src/on.ts +++ b/packages/@glimmer/modifier/src/on.ts @@ -1,111 +1,56 @@ -import { ModifierManager, VMArguments, CapturedArguments } from '@glimmer/interfaces'; -import { SimpleElement } from '@simple-dom/interface'; -import { Tag, CONSTANT_TAG } from '@glimmer/validator'; - -class OnModifierState { - public element: Element; - private args: CapturedArguments; - public tag: Tag; - - public eventName: string; - public callback: EventListener; - - public shouldUpdate = true; - - constructor(element: Element, args: CapturedArguments) { - this.element = element; - this.args = args; - this.tag = args.tag; - } - - updateFromArgs(): void { - const { args } = this; - - const eventName = args.positional.at(0).value() as string; - - if (eventName !== this.eventName) { - this.eventName = eventName; - this.shouldUpdate = true; - } - - const callback = args.positional.at(1).value() as EventListener; - - if (callback !== this.callback) { - this.callback = callback; - this.shouldUpdate = true; - } - } - - destroy(): void { - this.element.removeEventListener(this.eventName, this.callback); - } +import { ModifierManager, modifierCapabilities, setModifierManager } from '@glimmer/core'; + +export function on( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + element: Element, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [eventName, callback]: [string, EventListenerOrEventListenerObject], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + options?: AddEventListenerOptions +// eslint-disable-next-line @typescript-eslint/no-empty-function +): void {} + +interface OnArgs { + positional: [string, EventListenerOrEventListenerObject]; + named: AddEventListenerOptions; } -class OnModifierManager implements ModifierManager { - public isInteractive: boolean; - - constructor() { - this.isInteractive = typeof document !== 'undefined'; - } - - create(element: SimpleElement, _state: null, args: VMArguments): OnModifierState | null { - if (!this.isInteractive) { - return null; - } +interface OnStateBucket { + element?: Element; + args: OnArgs; + previousArgs: OnArgs; +} - const capturedArgs = args.capture(); - return new OnModifierState(element as Element, capturedArgs); - } +class OnModifierManager implements ModifierManager { + capabilities = modifierCapabilities('3.13'); - getTag(state: OnModifierState | null): Tag { - if (state === null) { - return CONSTANT_TAG; - } - return state.tag; + createModifier(definition: {}, args: unknown): OnStateBucket { + return { args: args as OnArgs, previousArgs: args as OnArgs }; } - install(state: OnModifierState | null): void { - if (state === null) { - return; - } - - state.updateFromArgs(); + installModifier(bucket: OnStateBucket, element: Element): void { + const { args } = bucket; + const [eventName, listener] = args.positional; + const named = Object.assign({}, args.named); - const { element, eventName, callback } = state; - element.addEventListener(eventName, callback); + element.addEventListener(eventName, listener, named); - state.shouldUpdate = false; + bucket.element = element; + bucket.previousArgs = { + positional: [eventName, listener], + named, + }; } - update(state: OnModifierState | null): void { - if (state === null) { - return; - } - - // stash prior state for el.removeEventListener - const { element, eventName, callback } = state; - - state.updateFromArgs(); - - if (!state.shouldUpdate) { - return; - } - - // use prior state values for removal - element.removeEventListener(eventName, callback); - - // read updated values from the state object - state.element.addEventListener(state.eventName, state.callback); - - state.shouldUpdate = false; + updateModifier(bucket: OnStateBucket): void { + this.destroyModifier(bucket); + this.installModifier(bucket, bucket.element!); } - getDestructor(state: OnModifierState | null): OnModifierState | null{ - return state; + destroyModifier({ element, previousArgs }: OnStateBucket): void { + const [eventName, listener] = previousArgs.positional; + element!.removeEventListener(eventName, listener, previousArgs.named); } } -export const on = { - state: null, - manager: new OnModifierManager(), -}; +setModifierManager(() => new OnModifierManager(), on); diff --git a/packages/@glimmer/ssr/test/index.ts b/packages/@glimmer/ssr/test/index.ts index a014c5a2a..68240426e 100644 --- a/packages/@glimmer/ssr/test/index.ts +++ b/packages/@glimmer/ssr/test/index.ts @@ -1 +1,2 @@ import './render-options-tests'; +import './modifiers-test'; diff --git a/packages/@glimmer/ssr/test/modifiers-test.ts b/packages/@glimmer/ssr/test/modifiers-test.ts new file mode 100644 index 000000000..e6a21d759 --- /dev/null +++ b/packages/@glimmer/ssr/test/modifiers-test.ts @@ -0,0 +1,56 @@ +import { + setComponentTemplate, + createTemplate, + templateOnlyComponent, + setModifierManager, + modifierCapabilities, + ModifierManager, +} from '@glimmer/core'; +import { renderToString } from '..'; + +class CustomModifier { + element?: Element; + // eslint-disable-next-line @typescript-eslint/no-empty-function + didInsertElement(): void {} +} + +class CustomModifierManager implements ModifierManager { + capabilities = modifierCapabilities('3.13'); + + constructor(private owner: unknown) {} + + createModifier(factory: { new (owner: unknown): CustomModifier }): CustomModifier { + return new factory(this.owner); + } + + installModifier(instance: CustomModifier): void { + instance.didInsertElement(); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + updateModifier(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + destroyModifier(): void {} +} + +setModifierManager(owner => new CustomModifierManager(owner), CustomModifier); + +QUnit.module('@glimmer/ssr modifiers', () => { + QUnit.test('modifiers do not run in SSR', async assert => { + class Modifier extends CustomModifier { + didInsertElement(): void { + assert.ok(false, 'modifiers should not trigger insert in SSR'); + } + } + + const Component = templateOnlyComponent(); + setComponentTemplate( + Component, + createTemplate({ modifier: Modifier }, '

hello world

') + ); + + const output = await renderToString(Component); + + assert.equal(output, '

hello world

'); + }); +}); diff --git a/packages/@glimmer/tracking/index.ts b/packages/@glimmer/tracking/index.ts index 4397246ca..4c6c5807b 100644 --- a/packages/@glimmer/tracking/index.ts +++ b/packages/@glimmer/tracking/index.ts @@ -1 +1,2 @@ -export { tracked, setPropertyDidChange } from './src/tracked'; +export { setPropertyDidChange } from '@glimmer/validator'; +export { tracked } from './src/tracked'; diff --git a/packages/@glimmer/tracking/src/tracked.ts b/packages/@glimmer/tracking/src/tracked.ts index 26d4d8b33..9f5a647d5 100644 --- a/packages/@glimmer/tracking/src/tracked.ts +++ b/packages/@glimmer/tracking/src/tracked.ts @@ -139,15 +139,6 @@ function descriptorForField( // eslint-disable-next-line @typescript-eslint/no-explicit-any set(this: T, newValue: any): void { setter(this, newValue); - propertyDidChange(); }, }; } - -// eslint-disable-next-line @typescript-eslint/no-empty-function -let propertyDidChange = function(): void {}; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function setPropertyDidChange(cb: () => void) { - propertyDidChange = cb; -}