mitchlloyd / ember-islands

Render Ember components anywhere on a server-rendered page to create "Islands of Richness"
MIT License
236 stars 24 forks source link

Next Version of Ember Islands #14

Closed mitchlloyd closed 8 years ago

mitchlloyd commented 8 years ago

This addon works today, but as Ember and its ecosystem improve we have a chance to simplify this approach and even add some new features to further this project's goal.

That goal is to make Ember a viable framework in a server-rendered application. The hope is that users would introduce Ember into a small part of their application and then slowly refactor to a client-rendered app where Ember really shines. Any changes should support this goal.

I'd like to know about any concerns with this plan breaking old use cases and whether there are new use cases that should be considered for the next version.

Possibilities for Ember Islands Next

Droping Old Versions of Ember

By droping old versions of Ember (pre 2.0) we eliminate a lot of complexity and maintenance from the project. Dropping everything before Ember 2.3 will allow us to use the new Ember.getOwner API and remove the need for an initializer. This will make the process of looking up component-lookup:main less brittle.

Using the Visit API

As shown here the newest version of the visit api would allow this addon to use all public APIs for rendering.

In addition to supporting the current syntax:

<div data-component='my-component' data-attrs='{"title": "Component Title"}'>
  <p>Some optional innerContent</p>
</div>

This would also make it possible to render instances of an Ember application routed to specific URLs:

<div data-visit-url='/users/1' data-query-params='{"editing": "true"}'>
</div>

Applications that are rendered this way could use routing that is not tied to the address bar with the application { location: none } option.

The biggest issue with this approach is handling shared services. If you have a service that watches the window size, maintains a user session, or caches model data (Ember Data), you'll want services that can be shared between all of your application instances.

A visit test shows an approach of sharing services, but in practice I've found it difficult to make this work. I've especially had problems with other addons that register their own services. Creating and registering services early (in an application initializer) is possible, but breaks the paradigm of lazily instantiated services.

One workaround is to register services in a way that forces them to be singletons:

// In an application initializer

function wrapService(Service) {
  let serviceInstance;

  return Ember.Object.extend().reopenClass({
    create() {
      if (!serviceInstance) {
        serviceInstance = Service.create();
      }
      return serviceInstance;
    }
  });
}

export default {
  name: 'share-services',
  initialize: function(application) {
    application.register('service:shared-counting', wrapService(SharedCounting));
  }
};

The next step would be to look through the list of services in requirejs (pretty hacky stuff) and do something in the registry to make sure they are registered in this singleton style. I haven't been able to get this working with Ember Data yet.

Using this approach it seems natural to set autoboot to false and boot an application once for each Ember Island marker in the static HTML. This means that we need code outside of the Ember system to find the application and boot instances of it. This works, but again is a little hacky.

Another concern with this approach over the current one is that we can no longer pass all of data-attrs as the attrs of a component. Instead we would need to pass the attrs as one attribute like elementAttrs Once we have a splat operator the old functionality would be possible again.

I'm not sure if the ability to render different URLs of an Ember application provides much value.

Using a syntax like this:

<div data-visit-url='/users/1' data-query-params='{"editing": "true"}'>
</div>

doesn't lend itself to refactoring to a full Ember application as much as the component syntax does.

The goal of the visit approach was to find a more Ember-friendly way of rendering these components, but I've found it pretty akward to try treating many Ember applications as one. Manipulating the registry and creating my own boot process seems pretty complicated.

Simplifying the Current Approach

Use a Component Instead of Initializers

In the past we've used initializers to render the components onto the page. We've made it over the hump of Initializer Chaos™, but in the next version we can make things simpler by having users put a component into their application template:

{{ember-islands}}

This component would look for the special [data-component] tags on the page, and render them using the same approach used today (componentLookup & appendTo).

Drop "cancelRouting" feature

I used private APIs to cancel routing. This supported a use case where an Ember app was sometimes used in the context of a server-rendered application and sometimes used as a stand-alone client-rendered application. I plan to drop this support since I think this use case is easier to just handle with templating:

{{#if isInServerRenderedApp}}
  {{ember-islands}}
{{else}}
  {{my-other-content}}
{{/if}}

Using Ember Wormhole

Ember Wormhole uses private component APIs and knowledge of HTMLbars inner workings to accomplish something pretty similar to Ember Islands. Ember Islands uses the undocumented component-lookup utility to find components and then render them into elements on the page. Ember Islands has it pretty easy compared to Ember Wormhole because it does not need to teardown components, but the new {{ember-islands}} component probably does need to support component teardown to avoid surprising users that wrap it in an {{#if}} helper.

The primary advantage of using Ember Wormhole is that two project don't have to maintain code that relies on private Ember APIs -- any breakage could be handled in Ember Wormhole.

The primary disadvantage that I see is that, without a splat operator, the components would have to use a namespaced attribute like elementAttrs.

Current Plan

For a 1.0 release the current plan is:

  1. Hold off on using the application visit API.
  2. Hold off on using Ember Wormhole for now.
  3. Drop support for Ember 1.x.
  4. Remove the cancel routing feature.
  5. Introduce a new {{ember-islands}} component.

cc @praxxis, @chancancode, @himynameisjonas, @rwjblue

praxxis commented 8 years ago

Hold off on using the application visit API.

Losing shared services would likely be a non-starter for us. There are a couple of places where we have existing client side routing, and thus can't really use Ember routing. Shared services let us coordinate between multiple components on a page in the absence of the router.

Hold off on using Ember Wormhole for now.

:+1:

Drop support for Ember 1.x.

:+1:

Remove the cancel routing feature. Introduce a new {{ember-islands}} component.

I'm not sure where I stand on this one yet. In your example where would isInServerRenderedApp be defined? Is it a property I'm passing in to Ember.Application.create?

mitchlloyd commented 8 years ago

The idea behind using a component over an initializer is to give users control over rendering the components (when to do it / whether to do it) and make rending a little more idiomatic. Currently there are instructions about using a bypass flag to avoid rendering in the readme -- we could remove this configuration. The entire cancelRouting hack was introduced to handle a use case where someone wanted to render their application template in one deployment and not render it when using Ember islands in another deployment environment. Adding that feature was probably a mistake. Allowing someone to control their application.hbs content with the normal template helpers seems very preferable.

Finally I think this opens up new possibilities in the future like rendering and tearing down components by using {{#if}}, looping over components while rending special markup around them, and all the other great stuff we can normally do with components.

One way to fill out the example I posted:

{{#if isInServerRenderedApp}}
  {{ember-islands}}
{{else}}
  {{my-other-content}}
{{/if}}

Would be to put this in your application controller:

import config from 'my-app/config/environment.js';

export default Ember.Controller.extend({
  isInServerRenderedApp: computed(function() {
    return config.isInServerRenderedApp;
  })
});

Where you have defined the isInServerRenderedApp in different deployed environments. I'm not super clear on that use case to be honest, but the point is that is would be easy to control when rendering happens without learning about configuration flags.

praxxis commented 8 years ago

Thanks, that makes it much clearer. FWIW our use case (progressively enhancing a Rails app) only has the one environment, and we control whether Islands is on during application creation, which is manually done. But we could still achieve the same control with the component based initialization.

mitchlloyd commented 8 years ago

...we control whether Islands is on during application creation, which is manually done.

Could you expand a little on what you're doing here and why you're doing it? If I understand what you're doing I should be able to write the "Bypassing the Addon" section of the README. I also want to know if you're using workarounds that the library could handle.

praxxis commented 8 years ago

Sure. We have a combination of Ember enabled pages at the moment - 'island' pages, which have one or two components, enabled by ember-islands; and 'continent' pages, which are full Ember apps (routing and all), albeit apps contained within the Rails generated header and footer. For example, https://kickstarter.com/team is a continent app.

To facilitate this we have a simple Rails helper for including Ember:


def require_ember(into: nil)

    ember_options = {}.tap do |hash|
      if into
        # if we have a root element enable routing
        # otherwise, boot into islands mode (the default)
        hash[:rootElement] = into
        hash[:EMBER_ISLANDS] = {
          bypass: true
        }
      end
    end

    content_for :javascripts do
        # <do some specific stuff to include the Ember js files>
        "require('frontend/app')['default'].create(#{ember_options.to_json})";
    end
  end

You'd do something like require_ember(into: '#team-container') to include a full routed Ember app into the page.

We'd still be able to do something similar with the component example.

mitchlloyd commented 8 years ago

I think this new approach may open up some nice options.

We could drop the Ember Islands config flag in the Rails helper:

def require_ember(into: 'body')
  ember_options = { rootElement: into }

  content_for :javascripts do
    # <do some specific stuff to include the Ember js files>
    "require('frontend/app')['default'].create(#{ember_options.to_json})";
  end
end

Then let's say that your /dashboard page needs to use Ember Islands. Inside of your Ember app you could have a dashboard.hbs template like this:

{{! inside of app/templates/dashboard.hbs}}
{{ember-islands}}

Now we'll say the /team page doesn't need Ember Islands. You could have a team route and team template without any Ember Islands component.

{{! inside of app/templates/team.hbs}}
My plain ol' team stuff

Does this seem like it would work? The current next-version branch should work with all versions of Ember 2.x. Might be cool to try it out and I'd be happy to take a look with you if we can arrange a time outside of PDT working hours.

praxxis commented 8 years ago

Yea, I don't mind that. At the start of this process I figured island components would just kind of... exist, without being used in the rest of the Ember app. But grouping them under routes from the start lights a path towards actually migrating each page that has an island, by already having its URL in Ember.

Lemme find some time to give it a try and get back to you.