Skip to content

Commit

Permalink
feat(ssr): enhance support for Universal and SSR with stylesheets
Browse files Browse the repository at this point in the history
* Add `StyleService` class to manage application and retrieval of styles from elements in a
  platform-agnostic manner
* Add virtual stylesheet to store server styles, which applies default styles when breakpoint
  overrides are not present
* While not in the browser (ssr), intercept all style calls and reroute them to the virtual
  stylesheet.
* For server-side rendering, add a new type of MediaQueryList similar to the MockMediaQueryList
  to support manual activation/deactivation of breakpoints
* Add jasmine testing mode for SSR
* Add FlexLayoutServerModule to invoke SSR styling
* Remove unnecessary Renderer references and replace them with DOM APIs
* Add whitespace debugging mode for server styles

Fixes #373. Closes #567.

> See [Design Doc](https://docs.google.com/document/d/1fg04ihw42dJJHGd6fugdiBe39iJot8aErhiE7CjwfmQ/edit#)
  • Loading branch information
CaerusKaru authored and ThomasBurleson committed Feb 17, 2018
1 parent 04b9bfd commit cf5266a
Show file tree
Hide file tree
Showing 69 changed files with 2,021 additions and 1,138 deletions.
5 changes: 5 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
- env: "MODE=lint"
- env: "MODE=aot"
- env: "MODE=prerender"
- env: "MODE=ssr"
- env: "MODE=saucelabs_required"
- env: "MODE=browserstack_required"
- env: "MODE=travis_required"
Expand All @@ -39,6 +40,10 @@ env:
- BROWSER_PROVIDER_READY_FILE=/tmp/flex-layout-build/readyfile
- BROWSER_PROVIDER_ERROR_FILE=/tmp/flex-layout-build/errorfile

matrix:
allow_failures:
- env: "MODE=ssr"


before_install:
- source ./scripts/ci/env.sh
Expand Down
2 changes: 1 addition & 1 deletion build-config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Build configuration for the packaging tool. This file will be automatically detected and used
* to build the different packages inside of Material.
* to build the different packages inside of Layout.
*/
const {join} = require('path');

Expand Down
87 changes: 87 additions & 0 deletions guides/SSR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Using Flex Layout with Server-Side Rendering (SSR)

### Introduction

In the browser, Flex Layout works by utilizing the global `Window` object's
`MatchMedia` interface. When a breakpoint is activated/deactivated, the service
informs the Flex directives, which inject CSS styles inline as necessary.

Unfortunately, on the server, we have no access to the `MatchMedia` service,
and so when the view is rendered for users using SSR, none of the responsive
breakpoints (i.e. `fxFlex.sm`) are respected. This leads to a mismatch between
the initial view generated by the server, and the bootstrapped view generated
by the client.

The solution provided allows Flex Layout to inject static CSS into the head of
the DOM instead of inline, and taps in to the CSS `@media` breakpoint interface,
instead of the dynamic JavaScript `MatchMedia` interface.

This guide introduces how to incorporate this functionality into your apps, and
the limitations to be aware of when using this utility.

### Usage

#### Option 1: Generate static CSS on the server

1. Import the `FlexLayoutServerModule` into the server bundle for your app,
generally called `app.server.module.ts`:

```typescript
import {NgModule} from '@angular/core';
import {FlexLayoutServerModule} from '@angular/flex-layout/server';

@NgModule(({
imports: [
... other imports here
FlexLayoutServerModule,
]
}))
export class AppServerModule {}
```

2. That's it! Your app should now be configured to use the server-side
implementations of the Flex Layout utilities.


#### Option 2: Only generate inline styles (legacy option)

1. Simply don't import the `FlexLayoutServerModule`. You'll receive a warning
on bootstrap, but this won't prevent you from using the library, and the
warning won't be logged on the client side


#### Option 3: Generate no Flex Layout stylings on the server

1. Don't import the `FlexLayoutServerModule`
2. DO import the `SERVER_TOKEN` and provide it in your app as follows:

```typescript
import {SERVER_TOKEN} from '@angular/flex-layout';

{provide: SERVER_TOKEN, useValue: true}
```

3. This will tell Flex Layout to not generate server stylings. Please note that
if you provide this token *and* the `FlexLayoutServerModule`, stylings **will**
still be rendered

### Limitations

One of the deficiencies of SSR is the lack of a fully-capable DOM rendering
engine. As such, some functionality of the Flex Layout library is imparied.
For instance, some Flex directives search for parent nodes with flex stylings
applied to avoid overwriting styles. However, if those styles are defined in
a style block, the external component styles, or another stylesheet, Flex Layout
won't be able to find those styles on the server.

The workaround for this is to **inline all Flex-related styles** as necessary.
For instance, if in an external stylesheet you have a class that applies
`flex-direction` to an element, add that styling inline on the element the
class is applied to. Chances are the impact of this will be minimal, and the
stylings will be loaded correctly on bootstrap. However, it is an unfortunate
reality of SSR and the DOM implementation used on the server.

### References

The design doc for this utility can be found
[here](https://docs.google.com/document/d/1fg04ihw42dJJHGd6fugdiBe39iJot8aErhiE7CjwfmQ)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"lib:build:aot": "gulp ci:aot",
"lib:lint": "gulp lint",
"lib:test": "gulp test",
"lib:test:ssr": "gulp test:ssr",
"universal:build": "gulp universal:build",
"universal:ci:prerender": "gulp ci:prerender"
},
Expand Down
4 changes: 4 additions & 0 deletions scripts/ci/sources/mode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ is_unit() {
is_prerender() {
[[ "$MODE" = prerender ]]
}

is_ssr() {
[[ "$MODE" = ssr ]]
}
2 changes: 2 additions & 0 deletions scripts/ci/travis-testing.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ elif is_prerender; then
$(npm bin)/gulp ci:prerender
elif is_closure_compiler; then
./scripts/closure-compiler/build-devapp-bundle.sh
elif is_ssr; then
$(npm bin)/gulp ci:ssr
fi

teardown_tunnel
15 changes: 4 additions & 11 deletions src/lib/api/core/base-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,16 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ElementRef, Renderer2} from '@angular/core';
import {ElementRef} from '@angular/core';
import {BaseFxDirectiveAdapter} from './base-adapter';
import {expect} from '../../utils/testing/custom-matchers';
import {MediaMonitor} from '@angular/flex-layout/media-query';

export class MockElementRef extends ElementRef {
constructor() {
const nEl = document.createElement('DIV');
super(nEl);
this.nativeElement = nEl;
}
}
import {MediaMonitor} from '../../media-query/media-monitor';
import {StyleUtils} from '../../utils/styling/style-utils';

describe('BaseFxDirectiveAdapter class', () => {
let component;
beforeEach(() => {
component = new BaseFxDirectiveAdapter('', {} as MediaMonitor, new MockElementRef(), {} as Renderer2, {}); // tslint:disable-line:max-line-length
component = new BaseFxDirectiveAdapter('', {} as MediaMonitor, {} as ElementRef, {} as StyleUtils); // tslint:disable-line:max-line-length
});
describe('cacheInput', () => {
it('should call _cacheInputArray when source is an array', () => {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/api/core/base-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {ElementRef, Inject, PLATFORM_ID, Renderer2} from '@angular/core';
import {ElementRef} from '@angular/core';

import {BaseFxDirective} from './base';
import {ResponsiveActivation} from './responsive-activation';
import {MediaQuerySubscriber} from '../../media-query/media-change';
import {MediaMonitor} from '../../media-query/media-monitor';
import {StyleUtils} from '../../utils/styling/style-utils';


/**
Expand Down Expand Up @@ -48,9 +49,8 @@ export class BaseFxDirectiveAdapter extends BaseFxDirective {
constructor(protected _baseKey: string, // non-responsive @Input property name
protected _mediaMonitor: MediaMonitor,
protected _elementRef: ElementRef,
protected _renderer: Renderer2,
@Inject(PLATFORM_ID) protected _platformId: Object) {
super(_mediaMonitor, _elementRef, _renderer, _platformId);
protected _styler: StyleUtils) {
super(_mediaMonitor, _elementRef, _styler);
}

/**
Expand Down
44 changes: 19 additions & 25 deletions src/lib/api/core/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,13 @@ import {
SimpleChanges,
OnChanges,
SimpleChange,
Renderer2,
Inject,
PLATFORM_ID,
} from '@angular/core';

import {buildLayoutCSS} from '../../utils/layout-validator';
import {
StyleDefinition,
lookupStyle,
lookupInlineStyle,
applyStyleToElement,
applyStyleToElements,
lookupAttributeValue,
} from '../../utils/style-utils';
StyleUtils,
} from '../../utils/styling/style-utils';

import {ResponsiveActivation, KeyOptions} from '../core/responsive-activation';
import {MediaMonitor} from '../../media-query/media-monitor';
Expand Down Expand Up @@ -70,8 +63,7 @@ export abstract class BaseFxDirective implements OnDestroy, OnChanges {
*/
constructor(protected _mediaMonitor: MediaMonitor,
protected _elementRef: ElementRef,
protected _renderer: Renderer2,
@Inject(PLATFORM_ID) protected _platformId: Object) {
protected _styler: StyleUtils) {
}

// *********************************************
Expand All @@ -85,7 +77,7 @@ export abstract class BaseFxDirective implements OnDestroy, OnChanges {
return this._elementRef.nativeElement.parentNode;
}

protected get nativeElement(): any {
protected get nativeElement(): HTMLElement {
return this._elementRef.nativeElement;
}

Expand Down Expand Up @@ -137,19 +129,20 @@ export abstract class BaseFxDirective implements OnDestroy, OnChanges {

/**
* Quick accessor to the current HTMLElement's `display` style
* Note: this allows use to preserve the original style
* Note: this allows us to preserve the original style
* and optional restore it when the mediaQueries deactivate
*/
protected _getDisplayStyle(source: HTMLElement = this.nativeElement): string {
return lookupStyle(this._platformId, source || this.nativeElement, 'display');
const query = 'display';
return this._styler.lookupStyle(source, query);
}

/**
* Quick accessor to raw attribute value on the target DOM element
*/
protected _getAttributeValue(attribute: string,
source: HTMLElement = this.nativeElement): string {
return lookupAttributeValue(source || this.nativeElement, attribute);
return this._styler.lookupAttributeValue(source, attribute);
}

/**
Expand All @@ -158,36 +151,37 @@ export abstract class BaseFxDirective implements OnDestroy, OnChanges {
* Check inline style first then check computed (stylesheet) style.
* And optionally add the flow value to element's inline style.
*/
protected _getFlowDirection(target: any, addIfMissing = false): string {
protected _getFlowDirection(target: HTMLElement, addIfMissing = false): string {
let value = 'row';
let hasInlineValue = '';

if (target) {
value = lookupStyle(this._platformId, target, 'flex-direction') || 'row';
let hasInlineValue = lookupInlineStyle(target, 'flex-direction');
[value, hasInlineValue] = this._styler.getFlowDirection(target);

if (!hasInlineValue && addIfMissing) {
applyStyleToElements(this._renderer, buildLayoutCSS(value), [target]);
const style = buildLayoutCSS(value);
const elements = [target];
this._styler.applyStyleToElements(style, elements);
}
}

return value.trim();
return value.trim() || 'row';
}

/**
* Applies styles given via string pair or object map to the directive element.
*/
protected _applyStyleToElement(style: StyleDefinition,
value?: string | number,
nativeElement: any = this.nativeElement) {
let element = nativeElement || this.nativeElement;
applyStyleToElement(this._renderer, element, style, value);
element: HTMLElement = this.nativeElement) {
this._styler.applyStyleToElement(element, style, value);
}

/**
* Applies styles given via string pair or object map to the directive's element.
*/
protected _applyStyleToElements(style: StyleDefinition, elements: HTMLElement[ ]) {
applyStyleToElements(this._renderer, style, elements || []);
protected _applyStyleToElements(style: StyleDefinition, elements: HTMLElement[]) {
this._styler.applyStyleToElements(style, elements);
}

/**
Expand Down
24 changes: 18 additions & 6 deletions src/lib/api/ext/class.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Component} from '@angular/core';
import {CommonModule} from '@angular/common';
import {Component, PLATFORM_ID} from '@angular/core';
import {CommonModule, isPlatformServer} from '@angular/common';
import {ComponentFixture, TestBed, async, inject} from '@angular/core/testing';

import {customMatchers, expect} from '../../utils/testing/custom-matchers';
Expand All @@ -21,15 +21,19 @@ import {BreakPointRegistry} from '../../media-query/breakpoints/break-point-regi

import {ClassDirective} from './class';
import {MediaQueriesModule} from '../../media-query/_module';
import {ServerStylesheet} from '../../utils/styling/server-stylesheet';
import {StyleUtils} from '../../utils/styling/style-utils';

describe('class directive', () => {
let fixture: ComponentFixture<any>;
let matchMedia: MockMatchMedia;
let platformId: Object;
let createTestComponent = (template: string) => {
fixture = makeCreateTestComponent(() => TestClassComponent)(template);

inject([MatchMedia], (_matchMedia: MockMatchMedia) => {
inject([MatchMedia, PLATFORM_ID], (_matchMedia: MockMatchMedia, _platformId: Object) => {
matchMedia = _matchMedia;
platformId = _platformId;
})();
};

Expand All @@ -46,7 +50,9 @@ describe('class directive', () => {
declarations: [TestClassComponent, ClassDirective],
providers: [
BreakPointRegistry, DEFAULT_BREAKPOINTS_PROVIDER,
{provide: MatchMedia, useClass: MockMatchMedia}
{provide: MatchMedia, useClass: MockMatchMedia},
ServerStylesheet,
StyleUtils,
]
});
});
Expand Down Expand Up @@ -224,15 +230,21 @@ describe('class directive', () => {
fixture.detectChanges();
let button = queryFor(fixture, '[mat-raised-button]')[0].nativeElement;

expect(button).toHaveCssClass('mat-raised-button');
// TODO(CaerusKaru): MatButton doesn't apply host attributes on the server
if (!isPlatformServer(platformId)) {
expect(button).toHaveCssClass('mat-raised-button');
}
expect(button).toHaveCssClass('btn-xs');
expect(button).toHaveCssClass('mat-primary');

fixture.componentInstance.formButtonXs = false;
fixture.detectChanges();
button = queryFor(fixture, '[mat-raised-button]')[0].nativeElement;

expect(button).toHaveCssClass('mat-raised-button');
// TODO(CaerusKaru): MatButton doesn't apply host attributes on the server
if (!isPlatformServer(platformId)) {
expect(button).toHaveCssClass('mat-raised-button');
}
expect(button).not.toHaveCssClass('btn-xs');
expect(button).toHaveCssClass('mat-primary');
});
Expand Down
Loading

0 comments on commit cf5266a

Please sign in to comment.