Skip to content

Commit

Permalink
Modernize components and prepare for v2 (#61)
Browse files Browse the repository at this point in the history
* Add type declarations for public APIs

* Clean up `implementationMap` inconsistencies

* Modernize Exclaim components

* Officially deprecate `unwrap`

* Modernize playground-app Exclaim implementations

* Modernize playground-app itself

* Update README and GLOSSARY

* Add v1 -> v2 migration notes to the README

* Turn on `embroider-optimized`?

* Don't rely on `@cached`

* Re-add missing `Environment` type

* Ensure whitespace is always trimmed

* Add undeclared `@glimmer` dependencies
  • Loading branch information
dfreeman authored Mar 20, 2024
1 parent c4b6130 commit f1b503b
Show file tree
Hide file tree
Showing 74 changed files with 743 additions and 634 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ jobs:
- ember-lts-4.4
- ember-release
- embroider-safe
# Disabled pending a shift away from string-based `implementationMap` lookups
# - embroider-optimized
- embroider-optimized

steps:
- uses: actions/checkout@v3
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The entry point to a UI powered by ember-exclaim is the `<ExclaimUi>` component.
- `@implementationMap`: a mapping of names in the `ui` config to information about their backing implementations
- `@onChange(envPathOfChangedValue)`: an optional function that will be invoked when a value in the `env` changes
- `@wrapper`: an optional component that will wrap every rendered component in your UI configuration. The `wrapper` component will receive the `ComponentSpec` as `@spec` ([more on `ComponentSpec` here](ember-exclaim/src/-private/GLOSSARY.md)), the `Environment` as `@env` and the component's resolved `@config`.
- `@useClassicReactivity`: an optional flag that, if set, will cause any environment bindings Exclaim constructs to use classic Ember `computed` machinery rather than native getters and setters that assume data is appropriately `@tracked`.

Each of these things is described in further detail below.

Expand Down Expand Up @@ -161,19 +162,19 @@ Note that `$bind` works with paths, too, so `{ $bind: 'foo.bar' }` would access
### The Implementation Map

The `@implementationMap` given to `<ExclaimUi>` dictates what components it can render. It should be a hash whose keys are the component and helper names available for use in the UI config. The value for each key should itself be a hash describing the component or helper with that name.
- `componentPath` (for components): the name to the Ember component to be invoked when this exclaim-ui component is used in the config, as you'd give it to the `{{component}}` helper
- `component` (for components): the name to the Ember component to be invoked when this exclaim-ui component is used in the config, as you'd give it to the `{{component}}` helper
- `helper` (for helper functions): a function that receives a `config` hash and `env` information and should return the output value for the helper
- `shorthandProperty` (optional for both helpers and components): the name of a property that should be populated when shorthand notation is used for this component or helper (see above)

## Implementing Components

The [demo app](https://salsify.github.io/ember-exclaim) for this repo contains [a variety of simple component implementations](tests/dummy/app/components/exclaim-components) that you can use as a starting point for building your own.

An ember-exclaim component implementation will receive two properties when rendered: `config` and `env`.
An ember-exclaim component implementation will receive two arguments when rendered: `@config` and `@env`.

### `@config`

The `@config` argument of the implementing component will contain all other information supplied in the `$component` hash representing it in the UI config. Any `$bind` directives in that config will be automatically be resolved when they are `get` or `set`. As an example, consider a lightweight implementation of the `input` component mentioned above.
The `@config` argument of the implementing component will contain all other information supplied in the `$component` hash representing it in the UI config. Any `$bind` directives in that config will be automatically be resolved when they are read or written. As an example, consider a lightweight implementation of the `input` component mentioned above.

```hbs
<input type="text" value={{@config.value}} oninput={{action (mut @config.value) value='target.value'}}>
Expand All @@ -197,10 +198,27 @@ For example, the [`vbox`](tests/dummy/app/components/exclaim-components/vbox) co
{{/each}}
```

By default, children will inherit the environment of their parent. This environment can be extended by passing a POJO with additional key/value pairs as a second parameter to `{{yield}}`. Check the implementation of [`each`](playground-app/app/components/exclaim-components/each) and [`let`](playground-app/app/components/exclaim-components/let) in the demo app for examples of how this can be used.
By default, children will inherit the environment of their parent. This environment can be extended by passing a POJO with additional key/value pairs as a second parameter to `{{yield}}`. Check [the implementation of `each` and `let`](playground-app/app/components/exclaim-components/) in the demo app for examples of how this can be used.

## Implementing Helpers

The [demo app](https://salsify.github.io/ember-exclaim) for this repo contains [a handful of helper implementations](tests/dummy/app/utils/exclaim-helpers) that you can use as a starting point for building your own.

An ember-exclaim helper implementation is simply a function that takes two arguments, `config` and `env`, which are the same two values described for components above. The value returned when this function is called will be the ultimate value of the `{ $helper: ... }` hash in the UI configuration.

## Migrating from v1 to v2

The v2 release of `ember-exclaim` simplified and modernized the internals of the addon to enable clean operation against `@tracked` data, while also eliminating the need for using `get` and `unwrap` when working with data using the classic `computed` reactivity model. In addition, some inconsistencies and overly-complex APIs that had organically evolved over the course of v1 were cleaned up, resulting in a handful of breaking changes:

- Exclaim now requires Ember 3.28+ and has dropped support for Internet Explorer.
- By default, `ExclaimUi` now uses native getters and setters for helpers and bindings in UI config, assuming data in the environment is appropriately `@tracked`.
- Support for the "classic" `computed` reactivity model is now opt-in via the `@useClassicReactivity` flag on `ExclaimUi`.
- Calling `.get()` or `.set()` on an object retrieved from a component's config or environment is now deprecated with the classic reactivity model, and fully unavailable under the tracked model. Fields on config or the environment may be read via direct access, and should use Ember's importable `set` if they require classic reactivity semantics.
- The `@env` passed into `ExclaimUi` is no longer wrapped in an `Environment` object
- It no longer automatically has `EmberObject` methods such as `get` and `set`.
- There is no longer an `.extend()` method; instead of yielding `this.args.env.extend(someExtraData)` to expose extra data to children, components should just yield `someExtraData`.
- The rarely-used `wrap` export has been removed, and the less-rarely-used `unwrap` export is now a deprecated no-op.
- The `@resolveFieldMeta` arg and `metaForField` env method have been removed.
- The shape of the implementation map has been adjusted:
- `componentPath` is now `component`, and expects a `ComponentLike` value rather than a string
- `componentMeta` and `helperMeta` have been renamed simply `meta`
3 changes: 2 additions & 1 deletion ember-exclaim/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/LICENSE.md
# Build output
/dist/
/declarations/
# We don't ignore declarations as they're currently hand-written.
# /declarations/
# npm/pnpm/yarn pack output
*.tgz
67 changes: 67 additions & 0 deletions ember-exclaim/declarations/components/exclaim-ui.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ComponentLike } from '@glint/template';
import { ComponentSpec, ImplementationMap } from '../index';

declare const ExclaimUi: ComponentLike<{
Args: {
/**
* A spec for an Exclaim UI.
*/
ui: unknown;

/**
* A mapping of names to implementations for use by `$component`
* and `$helper` in the given UI spec.
*/
implementationMap: ImplementationMap;

/**
* The backing data that any `$bind` in the UI spec will be bound
* to. This value will also be directly available as `@env` to all
* components rendered in this UI.
*/
env?: unknown;

/**
* A callback that will be invoked when the given path in the
* environment has changed because a `$bind` value was written.
*/
onChange?: (envPath: string) => void;

/**
* Set this flag `true` to use `computed`-based reactivity for managings
* bindings and helpers within this UI. When `false` or unset, native
* getters and setters will be used instead.
*/
useClassicReactivity?: boolean;

/**
* An optional component that, if provided, will be invoked around each
* component in this UI.
*/
wrapper?: ComponentLike<{
Args: {
/** The {@link ComponentSpec} being wrapped. */
componentSpec: ComponentSpec;

/** The resolved config object the wrapped component will receive. */
config: unknown;

/** The env object the wrapped component will receive. */
env: unknown;
};
Blocks: {
default: [];
};
}>;
};

Blocks: {
/**
* If an error is encountered when processing the given UI spec, it
* will be yielded to the default block.
*/
default: [error: unknown];
};
}>;

export default ExclaimUi;
63 changes: 63 additions & 0 deletions ember-exclaim/declarations/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { ComponentLike } from '@glint/template';

declare const Env: unique symbol;

/** A marker type for objects that are operating as an Exclaim environment. */
export type Environment<T> = T & Record<typeof Env, undefined>;

/**
* Returns the environment and source path within it where the field at
* the path on the given object originates, if known.
*/
export function resolveEnvPath(
object: unknown,
path: string,
): string | undefined;

/** The data defining a helper in an {@link ImplementationMap} */
export type HelperImplementation = {
helper: (...args: Array<never>) => unknown;
shorthandProperty?: string;
meta?: unknown;
};

/** The data defining a component in an {@link ImplementationMap} */
export type ComponentImplementation = {
component: ComponentLike<unknown>;
shorthandProperty?: string;
meta?: unknown;
};

/**
* A mapping of names that can be used in a UI spec with `$helper`
* or `$component` to the runtime implementation that should be invoked
* when that name is encountered.
*/
export type ImplementationMap<Names extends string = string> = Record<
Names,
HelperImplementation | ComponentImplementation
>;

/** Represents a `$component` object in a UI spec. */
export class ComponentSpec {
readonly component: ComponentLike<unknown>;
readonly config: unknown;
readonly meta: unknown;

resolveConfig(env: Environment<unknown>): unknown;
}

/** Represents a `$helper` object in a UI spec. */
export class HelperSpec {
readonly helper: (...args: Array<never>) => unknown;
readonly config: unknown;
readonly meta: unknown;
readonly bindings: Array<unknown>;

invoke(env: Environment<unknown>): unknown;
}

/**
* @deprecated This function is now a no-op and should no longer be used.
*/
export function unwrap<T>(value: T): T;
5 changes: 5 additions & 0 deletions ember-exclaim/declarations/template-registry.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ExclaimUi } from './components/exclaim-ui';

export default interface ExclaimTemplateRegistry {
ExclaimUi: typeof ExclaimUi;
}
24 changes: 21 additions & 3 deletions ember-exclaim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@
},
"dependencies": {
"@embroider/addon-shim": "^1.8.7",
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
"botanist": "^1.3.0",
"decorator-transforms": "^1.0.1",
"tracked-built-ins": "^3.3.0"
},
"peerDependencies": {
"@glint/template": "^1.3.0"
},
"devDependencies": {
"@babel/core": "^7.23.6",
"@babel/eslint-parser": "^7.23.3",
"@babel/runtime": "^7.17.0",
"@embroider/addon-dev": "^4.1.0",
"@glint/template": "^1.3.0",
"@rollup/plugin-babel": "^6.0.4",
"babel-plugin-ember-template-compilation": "^2.2.1",
"concurrently": "^8.2.2",
Expand Down Expand Up @@ -69,13 +75,25 @@
"main": "addon-main.cjs",
"app-js": {
"./components/exclaim-component.js": "./dist/_app_/components/exclaim-component.js",
"./components/exclaim-default-component-wrapper.js": "./dist/_app_/components/exclaim-default-component-wrapper.js",
"./components/exclaim-ui.js": "./dist/_app_/components/exclaim-ui.js"
}
},
"typesVersions": {
"*": {
"*": [
"declarations/*"
]
}
},
"exports": {
".": "./dist/index.js",
"./*": "./dist/*.js",
".": {
"types": "./declarations/index.d.ts",
"default": "./dist/index.js"
},
"./*": {
"types": "./declarations/*.d.ts",
"default": "./dist/*.js"
},
"./addon-main.js": "./addon-main.cjs"
}
}
8 changes: 2 additions & 6 deletions ember-exclaim/src/-private/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ A **`HelperSpec`** is the information necessary to compute the value of a helper

Any hash in the UI config with a `$helper` key will be transformed into a `HelperSpec` instance, following the same resolution rules as `ComponentSpec`s do, but looking for a `helper` function in the `implementationMap` rather than a `componentPath`.

A **`Binding`** is a reference to some value available in the salient `Environment` (see below), much like a variable reference in a programming language. A `Binding` is meaningless on its own, and must always be evaluated in the context of some `Environment`. Note that the `config` for a `ComponentSpec` may contain `Binding`s, which won't be resolved until they're actually used. This allows components to evaluate parts of their config in varying contexts, such as an `each` component rendering the same subcomponent config with varying values for its iteration variable.
A **`Binding`** is a reference to some value available in the salient _environment_ (see below), much like a variable reference in a programming language. A `Binding` is meaningless on its own, and must always be evaluated in the context of some `Environment`. Note that the `config` for a `ComponentSpec` may contain `Binding`s, which won't be resolved until they're actually used. This allows components to evaluate parts of their config in varying contexts, such as an `each` component rendering the same subcomponent config with varying values for its iteration variable.

## Runtime Elements

At runtime, exclaim UIs are evaluated relative to an **`Environment`**, which is analogous to scope in a programming language. An `Environment` contains all the bound values that are available to `Binding`s, and may itself contain `Binding` instances that point at other data within itself. When the time comes to resolve the `config` for a component to actual values, `ComponentSpec` instances expose a `resolveConfig(environment)` method, which returns an `EnvironmentData` instance for the configuration.

An **`EnvironmentData`** object is a proxy for some arbitrary hash or array that resolves any `Binding`s it contains relative to some `Environment`. You can think of `EnvironmentData` as a piece of data that remembers where it came from. For instance, given an `EnvironmentData` instance `data` wrapping the hash `{ hi: 'hello', bye: new Binding('farewell') }`, calling `data.get('hi')` would return the string `'hello'`, and calling `data.get('bye')` would return whatever the associated `Environment` contains for the key `farewell`.

Implementation note: when an `Environment` or `EnvironmentData` instance is asked to `get` a property, it first inspects whether the underlying value for that property is a `Binding`, and if so, resolves it. Once this resolution has occurred the first time, a computed property is generated so that subsequent lookups don't have to re-resolve, and changes to the underlying bound property will be reflected on the host `EnvironmentData` or `Environment`. Any non-primitive result of a `get` on an `Environment` or `EnvironmentData` instance will itself be a `EnvironmentData`, so that `Binding`s nested arbitrarily deep will always be resolved. There is also an `EnvironmentData` variant called `EnvironmentArray` which functions similarly but wraps arrays rather than objects.
At runtime, exclaim UIs are evaluated relative to an _environment_, which is analogous to scope in a programming language. An environment contains all the bound values that are available to `Binding`s. When the time comes to resolve the `config` for a component to actual values, `ComponentSpec` instances expose a `resolveConfig(environment)` method, which creates a copy of its configuration with any `Binding` or `HelperSpec` instances replaced with appropriate getters and setters that will read data from the given environment.
21 changes: 9 additions & 12 deletions ember-exclaim/src/-private/build-spec-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function buildBaseRules(implementationMap) {
return new HelperSpec(
implementationMap[name].helper,
config,
implementationMap[name].helperMeta,
implementationMap[name].meta,
);
} else {
throw new Error(`Unable to resolve helper ${name}`);
Expand All @@ -42,12 +42,12 @@ function buildBaseRules(implementationMap) {
({ name, config }) => {
if (
hasOwnProperty(implementationMap, name) &&
implementationMap[name].componentPath
implementationMap[name].component
) {
return new ComponentSpec(
implementationMap[name].componentPath,
implementationMap[name].component,
config,
implementationMap[name].componentMeta,
implementationMap[name].meta,
);
} else {
throw new Error(`Unable to resolve component ${name}`);
Expand All @@ -63,7 +63,7 @@ function buildShorthandRules(implementationMap) {
Object.keys(implementationMap).forEach((name) => {
let details = implementationMap[name];
if (details.shorthandProperty) {
if (details.componentPath) {
if (details.component) {
rules.push(buildComponentRule(name, details));
} else if (details.helper) {
rules.push(buildHelperRule(name, details));
Expand All @@ -74,25 +74,22 @@ function buildShorthandRules(implementationMap) {
return rules;
}

function buildComponentRule(
name,
{ shorthandProperty, componentPath, componentMeta },
) {
function buildComponentRule(name, { shorthandProperty, component, meta }) {
return rule(
{ [`$${name}`]: subtree('shorthandValue'), ...rest('config') },
({ shorthandValue, config }) => {
let fullConfig = { [shorthandProperty]: shorthandValue, ...config };
return new ComponentSpec(componentPath, fullConfig, componentMeta);
return new ComponentSpec(component, fullConfig, meta);
},
);
}

function buildHelperRule(name, { shorthandProperty, helper, helperMeta }) {
function buildHelperRule(name, { shorthandProperty, helper, meta }) {
return rule(
{ [`$${name}`]: subtree('shorthandValue'), ...rest('config') },
({ shorthandValue, config }) => {
let fullConfig = { [shorthandProperty]: shorthandValue, ...config };
return new HelperSpec(helper, fullConfig, helperMeta);
return new HelperSpec(helper, fullConfig, meta);
},
);
}
4 changes: 4 additions & 0 deletions ember-exclaim/src/-private/env/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export function isEnv(data) {
}

export function extendEnv(env, newBindings) {
if (!newBindings || !Object.keys(newBindings).length) {
return env;
}

const internals = envInternals.get(env);
const newEnv = internals.extend(env, newBindings);
const onChange = (key) => {
Expand Down
36 changes: 19 additions & 17 deletions ember-exclaim/src/components/exclaim-component.hbs
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
{{#component
@wrapper
componentSpec=@componentSpec
env=this.effectiveEnv
config=this.resolvedConfig
}}
{{#component
@componentSpec.component
config=this.resolvedConfig
env=this.effectiveEnv
as |componentSpec overrideEnv|
~}}
<ExclaimComponent
<@wrapper
@componentSpec={{@componentSpec}}
@env={{this.componentData.env}}
@config={{this.componentData.config}}
>
{{~null~}}
<@componentSpec.component
@config={{this.componentData.config}}
@env={{this.componentData.env}}
as |componentSpec additionalEnvData|
>
{{~null~}}
<ExclaimComponent
@componentSpec={{componentSpec}}
@env={{this.effectiveEnv}}
@overrideEnv={{overrideEnv}}
@env={{this.componentData.env}}
@additionalEnvData={{additionalEnvData}}
@wrapper={{@wrapper}}
/>
{{~/component}}
{{/component}}
{{~null~}}
</@componentSpec.component>
{{~null~}}
</@wrapper>
Loading

0 comments on commit f1b503b

Please sign in to comment.