Skip to content

Commit

Permalink
Add a tracked-based environment impl (#60)
Browse files Browse the repository at this point in the history
* Add a `tracked`-based environment impl

* Use a consistent technique for global storage

* Make `computed`-based reactivity opt-in

* Double up tests for computed vs tracked usage

* Use classic reactivity in the playground for now
  • Loading branch information
dfreeman authored Mar 18, 2024
1 parent 66c5d30 commit c4b6130
Show file tree
Hide file tree
Showing 12 changed files with 916 additions and 1,575 deletions.
3 changes: 2 additions & 1 deletion ember-exclaim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"dependencies": {
"@embroider/addon-shim": "^1.8.7",
"botanist": "^1.3.0",
"decorator-transforms": "^1.0.1"
"decorator-transforms": "^1.0.1",
"tracked-built-ins": "^3.3.0"
},
"devDependencies": {
"@babel/core": "^7.23.6",
Expand Down
6 changes: 1 addition & 5 deletions ember-exclaim/src/-private/env/computed.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import { triggerChange } from './index.js';

// This module contains implementations of key operations
// (namely `extend` and `bind`) for environments whose
// reactivity model is based on Ember computeds. The idea
// is that we should be able to make a similar (but simpler)
// one that just uses native getters and setters to instead
// work more cleanly with `@tracked` data and have `ExclaimUi`
// decide which version to pass to `makeEnv` based on an arg.
// reactivity model is based on Ember computeds.

/**
* Returns wrapper around the given environment object that
Expand Down
21 changes: 11 additions & 10 deletions ember-exclaim/src/-private/env/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
const EnvInternals = Symbol.for('env-internals');
// This fun bit of line noise is a workaround for
// https://github.com/embroider-build/ember-auto-import/issues/503#issuecomment-1064405138
const envInternals = (globalThis[Symbol.for('exclaim-env-internals')] ??=
new WeakMap());

export function makeEnv(data, onChange, { bind, extend }) {
return setInternals(data, { onChange, bind, extend });
}

export function isEnv(data) {
return EnvInternals in data;
return envInternals.has(data);
}

export function extendEnv(env, newBindings) {
const internals = env[EnvInternals];
const internals = envInternals.get(env);
const newEnv = internals.extend(env, newBindings);
const onChange = (key) => {
// We only want to propagate `onChange` if it's to a key in the
Expand All @@ -24,19 +27,17 @@ export function extendEnv(env, newBindings) {
}

export function bindData(config, env) {
return env[EnvInternals].bind(config, env);
return envInternals.get(env).bind(config, env);
}

export function triggerChange(env, key) {
env[EnvInternals].onChange?.(key);
envInternals.get(env).onChange?.(key);
}

function setInternals(env, internals) {
Object.defineProperty(env, EnvInternals, {
enumerable: false,
configurable: false,
get: () => internals,
});
if (!isEnv(env)) {
envInternals.set(env, internals);
}

return env;
}
88 changes: 88 additions & 0 deletions ember-exclaim/src/-private/env/tracked.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { TrackedObject } from 'tracked-built-ins';
import { HelperSpec, Binding } from '../ui-spec.js';
import { recordCanonicalPath } from '../paths.js';
import { triggerChange } from './index.js';

// This module contains implementations of key operations
// (namely `extend` and `bind`) for environments whose
// reactivity model is based on native getters and setters
// and therefore works cleanly with `@tracked` fields in
// environments.

/**
* Returns wrapper around the given environment object that
* will essentially behave exactly the same unless one of
* the added/overridden fields is accessed instead.
*/
export function extend(env, extraFields) {
const storage = new TrackedObject(extraFields);
return new Proxy(env, {
get(target, key) {
return Reflect.get(key in storage ? storage : target, key);
},
set(target, key, value) {
return Reflect.set(key in storage ? storage : target, key, value);
},
});
}

/**
* Given a piece of a UI spec `data` and an environment `env`,
* locates all `Binding` and `HelperSpec` values and installs
* appropriate getters and setters in their place.
*
* Note that this does not recurse through `ComponentSpec` values,
* as the embedded config within those should not be bound until
* the component spec is yielded and we know what environment to
* bind it to.
*/
export function bind(data, env) {
if (Array.isArray(data)) {
let result = Array(data.length);
for (let i = 0; i < data.length; i++) {
bindKey(result, i, data[i], env);
}
return result;
} else if (
typeof data === 'object' &&
data &&
Object.getPrototypeOf(data) === Object.prototype
) {
let result = {};
for (let key of Object.keys(data)) {
bindKey(result, key, data[key], env);
}
return result;
} else {
return data;
}
}

function bindKey(host, key, value, env) {
if (value instanceof Binding) {
const bindingPath = value.path.join('.');

recordCanonicalPath(host, key, env, bindingPath);
Object.defineProperty(host, key, {
enumerable: true,
get() {
return value.path.reduce((object, key) => object[key], env);
},
set(fieldValue) {
const parentPath = value.path.slice(0, -1);
const parent = parentPath.reduce((object, key) => object[key], env);
parent[value.path.at(-1)] = fieldValue;
triggerChange(env, bindingPath);
},
});
} else if (value instanceof HelperSpec) {
Object.defineProperty(host, key, {
enumerable: true,
get() {
return value.invoke(env);
},
});
} else {
host[key] = bind(value, env);
}
}
7 changes: 5 additions & 2 deletions ember-exclaim/src/components/exclaim-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import Component from '@ember/component';
import buildSpecProcessor from '../-private/build-spec-processor';
import { makeEnv } from '../-private/env/index.js';
import * as computedEnv from '../-private/env/computed.js';
import * as trackedEnv from '../-private/env/tracked.js';

export default Component.extend({
ui: null,
env: null,
implementationMap: null,
useClassicReactivity: false,

baseEnv: computed('env', 'onChange', function () {
return makeEnv(this.env ?? {}, this.onChange, computedEnv);
baseEnv: computed('env', 'onChange', 'useClassicReactivity', function () {
const envImpl = this.useClassicReactivity ? computedEnv : trackedEnv;
return makeEnv(this.env ?? {}, this.onChange, envImpl);
}),

content: computed('specProcessor', 'ui', function () {
Expand Down
1 change: 1 addition & 0 deletions playground-app/app/example/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
@ui={{json (or this.ui "{}")}}
@env={{json (or this.env "{}")}}
@implementationMap={{this.implementationMap}}
@useClassicReactivity={{true}}
local-class="result card"
as |error|
>
Expand Down
1 change: 1 addition & 0 deletions playground-app/app/index/template.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
env=(json this.envString)
implementationMap=this.implementationMap
wrapper=(component 'sample-wrapper')
useClassicReactivity=true
as |error|
}}
Error:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { htmlSafe } from '@ember/template';
import { setupRenderingTest } from 'ember-qunit';
import { resolveEnvPath } from 'ember-exclaim';

module('Integration | environment', function (hooks) {
module('Integration | environment | computed', function (hooks) {
setupRenderingTest(hooks);

function exclaimTest(name, { ui, env, implementationMap }) {
Expand Down Expand Up @@ -54,6 +54,7 @@ module('Integration | environment', function (hooks) {
@ui={{this.ui}}
@env={{this.env}}
@implementationMap={{this.implementationMap}}
@useClassicReactivity={{true}}
/>
`);

Expand Down
Loading

0 comments on commit c4b6130

Please sign in to comment.