Skip to content
Open
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
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
"typescript.tsdk": "node_modules/typescript/lib",
"glint.libraryPath": "./ember-async-data"
}
1 change: 1 addition & 0 deletions ember-async-data/.prettierrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ module.exports = {
},
},
],
plugins: ['prettier-plugin-ember-template-tag'],
};
9 changes: 7 additions & 2 deletions ember-async-data/babel.config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
{
"presets": [["@babel/preset-typescript"]],
"presets": [
[
"@babel/preset-typescript"
]
],
"plugins": [
"ember-template-imports/src/babel-plugin",
"@embroider/addon-dev/template-colocation-plugin",
["@babel/plugin-transform-typescript", { "allowDeclareFields": true }],
["@babel/plugin-proposal-decorators", { "version": "legacy" }],
"@babel/plugin-proposal-class-properties"
]
}
}
9 changes: 5 additions & 4 deletions ember-async-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,17 @@
"@embroider/addon-dev": "^4.1.0",
"@glimmer/component": "^1.1.2",
"@glimmer/tracking": "^1.1.2",
"@glint/core": "^1.0.2",
"@glint/environment-ember-loose": "^1.0.2",
"@glint/template": "^1.0.2",
"@glint/core": "^1.1.0",
"@glint/environment-ember-loose": "^1.1.0",
"@glint/environment-ember-template-imports": "^1.1.0",
"@glint/template": "^1.1.0",
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-node-resolve": "^15.1.0",
"@tsconfig/ember": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^6.2.1",
"@typescript-eslint/parser": "^6.2.1",
"concurrently": "^8.2.0",
"ember-source": "^4.12.3",
"ember-source": "^5.2.0",
"ember-template-lint": "^5.11.2",
"eslint": "^8.46.0",
"eslint-config-prettier": "^8.8.0",
Expand Down
75 changes: 75 additions & 0 deletions ember-async-data/rollup-plugin-template-tag.mjs
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This seemed to be the best approach we had when I spiked this out a few months ago, but I'm totally up for a different approach if we have something better now.

Copy link
Contributor

Choose a reason for hiding this comment

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import fs from "node:fs/promises";
import path from "node:path";
import { preprocessEmbeddedTemplates } from "ember-template-imports/lib/preprocess-embedded-templates.js";
import {
TEMPLATE_TAG_NAME,
TEMPLATE_TAG_PLACEHOLDER,
} from "ember-template-imports/lib/util.js";

export default function firstClassComponentTemplates() {
return {
name: "preprocess-fccts",
async resolveId(source, importer, options) {
if (source.endsWith(".hbs")) return;

for (let ext of ["", ".gjs", ".gts"]) {
let result = await this.resolve(source + ext, importer, {
...options,
skipSelf: true,
});

if (result?.external) {
return;
}

if (FCCT_EXTENSION.test(result?.id)) {
return resolutionFor(result.id);
}
}
},

async load(id) {
let originalId = this.getModuleInfo(id)?.meta?.fccts?.originalId ?? id;

if (originalId !== id) {
this.addWatchFile(originalId);
}

if (FCCT_EXTENSION.test(originalId)) {
return await preprocessTemplates(originalId);
}
},
};
}

const FCCT_EXTENSION = /\.g([jt]s)$/;

function resolutionFor(originalId) {
return {
id: originalId.replace(FCCT_EXTENSION, ".$1"),
meta: {
fccts: { originalId },
},
};
}

async function preprocessTemplates(id) {
let ember = (await import("ember-source")).default;
let contents = await fs.readFile(id, "utf-8");

// This is basically taken directly from `ember-template-imports`
let result = preprocessEmbeddedTemplates(contents, {
relativePath: path.relative(".", id),

getTemplateLocalsRequirePath: ember.absolutePaths.templateCompiler,
getTemplateLocalsExportPath: "_GlimmerSyntax.getTemplateLocals",

templateTag: TEMPLATE_TAG_NAME,
templateTagReplacement: TEMPLATE_TAG_PLACEHOLDER,

includeSourceMaps: true,
includeTemplateTokens: true,
});

return result.output;
}
13 changes: 8 additions & 5 deletions ember-async-data/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { babel } from '@rollup/plugin-babel';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { Addon } from '@embroider/addon-dev/rollup';
import templateTag from './rollup-plugin-template-tag.mjs';

const addon = new Addon({
srcDir: 'src',
destDir: 'dist',
srcDir: "src",
destDir: "dist",
});

// Add extensions here, such as ts, gjs, etc that you may import
const extensions = ['.js', '.ts'];
const extensions = ['.js', '.ts', '.gjs', '.gts'];

export default {
// This provides defaults that work well alongside `publicEntrypoints` below.
Expand All @@ -28,7 +29,9 @@ export default {
// These are the modules that should get reexported into the traditional
// "app" tree. Things in here should also be in publicEntrypoints above, but
// not everything in publicEntrypoints necessarily needs to go here.
addon.appReexports(['helpers/**/*.js']),
addon.appReexports(["helpers/**/*.js"]),

templateTag(),

// Follow the V2 Addon rules about dependencies. Your code can import from
// `dependencies` and `peerDependencies` as well as standard Ember-provided
Expand All @@ -54,7 +57,7 @@ export default {

// addons are allowed to contain imports of .css files, which we want rollup
// to leave alone and keep in the published output.
addon.keepAssets(['**/*.css']),
addon.keepAssets(["**/*.css"]),

// Remove leftover build artifacts when starting a new build.
addon.clean(),
Expand Down
81 changes: 81 additions & 0 deletions ember-async-data/src/components/async.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import TrackedAsyncData from '../tracked-async-data';

export interface AsyncSignature<T> {
Args: {
/** The `TrackedAsyncData` to render. */
data: T | Promise<T>;
};
Blocks: {
/** To render while waiting on the promise */
pending: [];
/** To render once the promise has resolved, with the resolved value. */
resolved: [value: T];
/** To render if the promise has rejected, with the associated reason. */
rejected: [reason: unknown];
};
}

/**
Render a `TrackedAsyncData` in a template, with named blocks for each of the
states the value may be in. This is somewhat nicer than doing the same with
an `if`/`else if` chain, and composes nicely with other template primitives.

It is also less error prone, in that you *cannot* access the value of the data
in the `pending` or `rejected` blocks: it is only yielded by the `resolved`
named block.

```ts
import { Async } from 'ember-async-data'
import LoadingSpinner from './loading-spinner';

function dumpError(error: unknown) {
return JSON.stringify(error);
}

let example = new TrackedAsyncData("hello");

<template>
<Async @data={{example}}>
<:pending>
<LoadingSpinner />
</:pending>

<:resolved as |value|>
<SomeComponent @arg={{value}} />
</:resolved>

<:rejected as |reason|>
<p>Whoops, something went wrong!</p>
<pre><code>
{{dumpErr reason}}
</code></pre>
</:rejected>
</Async>
</template>
```
*/
// IMPLEMENTATION NOTE: yes, future maintainer, this *does* have to be a class,
// not a template-only component. This is because it must be generic over the
// type passed in so that the yielded type can preserve that ("parametricity").
// That is: if we pass in a `TrackedAsyncData<string>`, the value yielded from
// the `resolved` named block should be a string. The only things which can
// preserve the type parameter that way are functions and classes, and we do not
// presently have a function-based way to define components, so we have to use
// a class instead to preserve the type!
export default class Async<T> extends Component<AsyncSignature<T>> {
@cached get data() {
return new TrackedAsyncData(this.args.data);
}

<template>
{{#if this.data.isResolved}}
{{yield this.data.value to='resolved'}}
{{else if this.data.isRejected}}
{{yield this.data.error to='rejected'}}
{{else}}
{{yield to='pending'}}
{{/if}}
</template>
}
1 change: 1 addition & 0 deletions ember-async-data/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { default as TrackedAsyncData } from './tracked-async-data';
export { load } from './helpers/load';
export { default as Async } from './components/async';
2 changes: 2 additions & 0 deletions ember-async-data/src/template-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// See https://typed-ember.gitbook.io/glint/using-glint/ember/authoring-addons

import type Load from './helpers/load';
import type Async from './components/async';

export default interface Registry {
load: typeof Load;
Async: typeof Async;
}
84 changes: 57 additions & 27 deletions ember-async-data/src/tracked-async-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,21 @@ class _TrackedAsyncData<T> {
toString(): string {
return JSON.stringify(this.toJSON(), null, 2);
}

match<V>(matcher: {
pending: () => V;
resolved: (value: T) => V;
rejected: (reason: unknown) => V;
}) {
switch (this.#state.data[0]) {
case 'PENDING':
return matcher.pending();
case 'RESOLVED':
return matcher.resolved(this.#state.data[1]);
case 'REJECTED':
return matcher.rejected(this.#state.data[1]);
}
}
}

/**
Expand Down Expand Up @@ -223,30 +238,38 @@ export type JSONRepr<T> =
// shared implementations (i.e. `.toJSON()` and `.toString()`) are shared
// automatically.

interface Pending<T> extends _TrackedAsyncData<T> {
/**
A `TrackedAsyncData` whose wrapped promise is still pending, and which
therefore does not have a `value` or an `error` property.
*/
interface Pending<T> extends Omit<_TrackedAsyncData<T>, 'value' | 'error'> {
state: 'PENDING';
isPending: true;
isResolved: false;
isRejected: false;
value: null;
error: null;
}

interface Resolved<T> extends _TrackedAsyncData<T> {
/**
A `TrackedAsyncData` whose wrapped promise has resolved, and which therefore
has a `value` property with the value the promise resolved to.
*/
interface Resolved<T> extends Omit<_TrackedAsyncData<T>, 'error'> {
state: 'RESOLVED';
isPending: false;
isResolved: true;
isRejected: false;
value: T;
error: null;
}

interface Rejected<T> extends _TrackedAsyncData<T> {
/**
A `TrackedAsyncData` whose wrapped promise has rejected, and which therefore
has an `error` property with the rejection value of the promise (if any).
*/
interface Rejected<T> extends Omit<_TrackedAsyncData<T>, 'value'> {
state: 'REJECTED';
isPending: false;
isResolved: false;
isRejected: true;
value: null;
error: unknown;
}

Expand All @@ -268,32 +291,39 @@ interface Rejected<T> extends _TrackedAsyncData<T> {
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import TrackedAsyncData from 'ember-async-data/tracked-async-data';
import type Store from '@ember-data/store';
import { TrackedAsyncData, Match } from 'ember-async-data';
import LoadingSpinner from './loading-spinner';
import PresentTheData from './present-the-data';

interface ProfileSignature {
Args: {
id: number;
};
}

export default class SmartProfile extends Component<{ id: number }> {
@service store;
export default class SmartProfile extends Component<ProfileSignature> {
@service declare store: Store;

@cached
get someData() {
@cached get someData() {
let recordPromise = this.store.findRecord('user', this.args.id);
return new TrackedAsyncData(recordPromise);
}
}
```

And a corresponding template:

```hbs
{{#if this.someData.isResolved}}
<PresentTheData @data={{this.someData.data}} />
{{else if this.someData.isPending}}
<LoadingSpinner />
{{else if this.someData.isRejected}}
<p>
Whoops! Looks like something went wrong!
{{this.someData.error.message}}
</p>
{{/if}}
<template>
<Match this.someData>
<:pending>
<LoadingSpinner />
</:pending>
<:resolved as |value|>
<PresentTheData @data={{value}} />
</:resolved>
<:rejected>
<p>Whoops! Looks like something went wrong!</p>
</:rejected>
</Match>
</template>
}
```
*/
type TrackedAsyncData<T> = Pending<T> | Resolved<T> | Rejected<T>;
Expand Down
Loading