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

Separate Application responsibilities #34

Merged
merged 2 commits into from
Oct 25, 2017

Conversation

tomdale
Copy link
Contributor

@tomdale tomdale commented Oct 23, 2017

As @chadhietala noted in #31, as the Glimmer VM has continued to expand its platform of capabilities, supporting the matrix of configuration options is becoming increasingly difficult.

Mode Client Server
DOM Rehydration X
DOM Building X
String Building (SEO) X
String Building (Rehydration) X
Lazy Compilation X X
Eager Compilation X X
Synchronous Rendering X X
Async/Incremental Rendering X X

Adding to the difficulty is the fact that some of the "modes" carry much more code than others. We need an API that makes configuration as simple as possible, while ensuring that the final payload only includes code for modes that are actually used.

This commit breaks the majority of the Application's responsibilities into three composable objects:

  1. The Loader, which may load and/or compile an application's templates.
  2. The Builder, which determines how template output is built (e.g. building DOM).
  3. The Renderer, which schedules rendering work.

Examples

Today

Today's Glimmer.js app has the following characteristics:

  1. Lazy, in-browser bytecode compilation.
  2. Non-rehydrating, client-side rendering.
  3. Synchronous initial render.

In the API proposed here, the bootstrapping code would look like this:

import Application, { DOMBuilder, SyncRenderer, RuntimeLoader } from '@glimmer/application';
import Resolver, { BasicModuleRegistry } from '@glimmer/resolver';
import moduleMap from '../config/module-map';
import resolverConfiguration from '../config/resolver-configuration';

let moduleRegistry = new BasicModuleRegistry(moduleMap);
let resolver = new Resolver(resolverConfiguration, moduleRegistry);

let element = document.getElementById('app');

let app = new Application({
  builder: new DOMBuilder({ element }),
  renderer: new SyncRenderer(),
  loader: new RuntimeLoader(resolver)
});

app.boot();

This is very similar to the code generated by the blueprint today. The only change is that, instead of passing the resolver and element to the Application constructor directly, we wrap them in a RuntimeLoader and DOMBuilder, respectively. We then pass the RuntimeLoader, DOMBuilder and SyncRenderer to the Application constructor.

So far, we've added a little bit of boilerplate for the same semantics. Not much of an improvement! Let's see where this gets more interesting.

Bytecode

Imagine we now want to move the final bytecode compilation step out of the browser and into our build process. To do this, we will compile our templates into a single binary .gbx file. Additionally, we want to incrementally perform the initial render, so lower-end devices stay responsive even if our DOM is large.

Now we can reconfigure our application like this:

import Application, { DOMBuilder, AsyncRenderer, BytecodeLoader } from '@glimmer/application';

import data from './__compiled__/data';
let bytecode = fetch('./__compiled__/templates.gbx')
  .then(req => req.arrayBuffer());

let element = document.getElementById('app');

let app = new Application({
  builder: new DOMBuilder({ element }),
  renderer: new AsyncRenderer(),
  loader: new BytecodeLoader({ data, bytecode })
});

app.boot().then(() => {/* ... */});

With very similar code to the example above, we've now radically changed how our application behaves. It will fetch the binary bytecode and pass it to the BytecodeLoader, which is much smaller and faster than the RuntimeLoader. Because we pass in the AsyncRenderer, the initial render will be batched via requestIdleCallback so the browser stays responsive, no matter how many frames it takes to render the whole page.

Additional Examples

For additional examples of how this API may be used to fine-tune the performance of a Glimmer.js application, see this gist.

Additional Changes

This PR is intentionally scoped as small as possible, with the hope that it can be merged early to avoid a long-lived branch. There are several shortcomings in the implementation here that I intend to address before we release:

  1. Given that renderers can be sync or async, we should move all user-facing Application methods to be async and return promises (boot() etc.).
  2. To ease getting the tests passing, the Application provides a default set of renderer, loader and builder if they are not passed to the constructor. We will want to update the test harness to pass the appropriate configuration, and throw an exception if the required objects are not passed to the Application constructor.

stdio: 'inherit',
preferLocal: true
});
} catch (e) { }

Choose a reason for hiding this comment

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

Should the error at least be logged?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

execa throws an exception if the return value is non-zero, so you end up with a useless stack trace every time the tests fail. Since we pipe stderr to the parent stderr you will see the error messages anyway. You're right though that we should probably check to verify that the error isn't caused by a bad path or something and is really a non-zero exit code.

Choose a reason for hiding this comment

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

Thanks for the context. Even if nothing is handled, a comment would probably help in regards to why the error is ignored 👍

Copy link
Member

Choose a reason for hiding this comment

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

@tomdale - Can you confirm that failing node tests properly fail the build now? My concern is that swallowing this error, will "eat" the non-zero exit code and the tests would seen as "passing" (due to process exiting with 0 exit code) when they actually failed...

Copy link
Member

Choose a reason for hiding this comment

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

Would like to not repeat what is done in the VM when it comes to swallowing the error.

Copy link
Member

Choose a reason for hiding this comment

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

haha, indeed!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@knownasilya The release version of execa hides all information from you in the case of a non-zero exit code in the sync case, which is really unfortunate. It looks like a PR was accepted 6 days ago to address this but hasn't been released yet.

@rwjblue In my tests, running yarn test with a failing Node test produces an exit code of 1, so I think we're good. I would expect Testem to treat TAP output that contained not ok as a failure, which it seems to be doing.

@tomdale tomdale force-pushed the separate-application-concerns branch from 8c2b3f3 to 9e0c888 Compare October 23, 2017 15:16
env.commit();
this._didRender();
});
} else {
Copy link
Member

Choose a reason for hiding this comment

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

I think we might need to guard all of this with if (result) { ... }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The guard is in SyncRenderer; the Application no longer knows about the render result.

@@ -231,38 +286,12 @@ export default class Application implements Owner {
* at the end of the browser's event loop.
*/
protected _rerender() {
let { env, _result: result } = this;
let { env } = this;
Copy link
Member

Choose a reason for hiding this comment

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

Remove

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍

} while (!result.done);

// Finally, commit the transaction and flush component lifecycle hooks.
this.renderer.rerender();
Copy link
Member

Choose a reason for hiding this comment

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

Re-render is synchronous but render is async? Seems like these should be balanced or at least check for the balancing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In practice, rerender cannot be async at the moment. The interface for rerender() already supports a Promise return type, but there aren't any implementations that can test this path yet.

import { Cursor, NewElementBuilder, Environment } from "@glimmer/runtime";
import { Builder } from "../application";

export interface DOMBuilderOptions {
Copy link
Member

Choose a reason for hiding this comment

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

This is a Cursor and the type is exposed from @glimmer/runtime as such. If you want a type alias for the same thing just extend it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@chadhietala Deny, Cursor does not allow nextSibling to be undefined. I don't think we want to make users have to explicitly set nextSibling to null every time.

Copy link
Member

@chadhietala chadhietala Oct 23, 2017

Choose a reason for hiding this comment

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

It is an Option. In the constructor just do { element, nextSibling = null }

chadhietala and others added 2 commits October 23, 2017 16:54
This commit breaks the Application's responsibilities into three
composable objects:

1. A Loader, which may load and/or compile an application's templates.
2. A Builder, which determines how template output is built (e.g.
   building DOM).
3. A Renderer, which schedules rendering work.
@tomdale tomdale force-pushed the separate-application-concerns branch from 8976fe3 to 773e7b7 Compare October 23, 2017 20:54
@chadhietala chadhietala merged commit 2c42843 into master Oct 25, 2017
@chadhietala chadhietala deleted the separate-application-concerns branch October 25, 2017 20:22
@knownasilya
Copy link

Woot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants