Skip to content

Instantly share code, notes, and snippets.

@aslushnikov
Created July 6, 2015 09:55
Show Gist options
  • Select an option

  • Save aslushnikov/9f00c07439ea49b62c0c to your computer and use it in GitHub Desktop.

Select an option

Save aslushnikov/9f00c07439ea49b62c0c to your computer and use it in GitHub Desktop.
CSS Layer v.2 edition 2
# CSS Layer v.2
The ultimate goals of the CSS design are:
- support the **Layout Mode** modifications of CSS model
- make *side-by-side matched styles of two nodes* user scenario possible
- support multiple clients per css domain
The work will result in subsystem with a clean API to fetch and edit CSS styles.
The document consists of three big parts:
1. Backend - where we discuss changes in the CSS agent code and its protocol.
2. Frontend Overview - where we roughly sketch the front-end design
3. Frontend implementation details - where we dive into front-end objects and implementation details.
## Backend
### 1. CSSAgent events
The `StyleSheetChanged` event was removed in favor of granular events. The total list of events:
- StyleSheet-wise events
- `StyleSheetAdded = { styleSheetId, StyleSheetHeaderPayload }`
- `StyleSheetRemoved = { styleSheetId }`
- *new* `StyleSheetTextUpdated = { styleSheetId }` - this happens whenever a non-model stylesheet text update happens to avoid a storm of model-wise change events.
- Model-wise events, which happen in a result of model-wise editing:
- `RuleSelectorChanged = { styleSheetId, oldRange, newText, selectorPayload }`
- `StyleTextChanged = { styleSheetId, oldRange, newText, stylePayload }`
- `MediaTextChanged = { styleSheetId, oldRange, newText, mediaPayload }`
- `RuleRemoved = { styleSheetId, oldRange, newText }`
- `RuleAdded = { styleSheetId, oldRange, newText, rulePayload }`
- MediaQuery events:
- `MediaQueryResultChanged`
> **NOTE** `RuleSelectorChanged` event sends `selectorPayload` instead of `rulePayload`
### 2. StyleSheedID deduplication
If two stylesheets have the same content and the same sourceURL, then they are given the same `styleSheetId`.
> **FIXME** What if some stylesheets with the same contents and same sourceURL have modified CSSOM?
### 3. Editing operation
`CSSAgent` supports three editing operations.
- `setRuleSelector`
- `setStyleText`
- `setMediaText`
These operations result in appropriate events sent from CSSAgent (e.g. `RuleSelectorChanged` is sent during the successful `setRuleSelector` operation). The operations themselves return `true` or `false` regarding of operation success/failure.
## Front-end High Level Overview
There is one big class in the front-end: `CSSModel`.
`CSSModel` listens to all backend events, deduplicates editable models, etc.
> **Goal**: Clients of CSS Subsystem should never listen to any CSSAgent (a.k.a. backend) events. Instead, they rely on highlevel model events.
`CSSModel` owns `CSSStyle`, `CSSSelectorList` and `CSSMedia` objects and updates them according to the various backend events.
> **Example**: `CSSModel` should update style while handling `StyleTextChanged` event.
There are three **editable** model classes: `CSSSelectorList`, `CSSStyle` and `CSSMedia`. All have a `setText` method and fire 3 events:
- `Obsolete` - happens as the model gets invalid (e.g. was removed, or if the text range failed to rebaseline).
- `RangeChanged` - whenever source range is changed. Useful for re-rendering anchors.
- `ModelUpdated` - whenever model data gets updated.
The `CSSRule` is a container which holds `CSSSelectorList` object and `CSSStyle` object.
The `CSSRule` can fire a single event - `Obsolete`. Once either of its selector or style becomes obsolete, the rule becomes obsolete too.
> **Note** `CSSRule` does not reference `CSSMedia` objects per se. This is a matched styles detail.
> **Note** The User-Agent and CSSOM-modified rules do not belong to any `CSSStyleSheet`; they are a part of `MatchedStyles` object.
> **Example** The rule will fire `Obsolete` once its parent `CSSStyleSheet` will handle `RuleRemoved` event and will remove it from its rules.
The `CSSModel` also provides 5 fetching methods:
- **[C]** `matchedStylesForNode` : `MatchedStyles`
- **[C]** `computedStyleForNode` : `ComputedStyle`
- **[C]** `inlineStyleForNode` : `InlineStyle`
- **[C]** `platformFontsForNode` : `PlatformFonts`
- **[C]** `allMediQueries` : `MediaList`
*(The **[C]** means "caching", more on this later)*
Every fetching method returns some specific model. Every model is capable of understanding when it gets unrelevant, and fires `Obsolete` event.
> **Example**: `MatchedStyles` might fire `Obsolete` on almost any `CSSAgent` event, as well as some `DOM` events.
Every fetching method does caching: it keeps returning the cached result once its not obsolete, and re-fetches the obsolete one.
## Front-end Implementation Details
### 1. Base Class: CSSObject
`CSSObject` is a base class for all of those who can send `Obsolete` event:
- `CSSRule`
- `CSSStyle`
- `MatchedStyles`
- ...
```javascript
CSSObject = function() {
this._obsolete = false;
}
CSSObject.Events = {
Obsolete
}
CSSObject.prototype = {
isObsolete: function() {
return this._obsolete;
},
obsolete: function() {
if (this._obsolete)
return;
this._obsolete = true;
FIRE(Obsolete);
}
}
```
### 2. CSSSelectorList, CSSStyle, CSSMedia
The only three **editable** models are `CSSSelectorList`, `CSSStyle` and `CSSMedia`.
They all look similar:
- They all provide the `Obsolete`, `RangeChanged` and `ModelUpdated` events.
- They all have `equals` method.
- They all have `initWithPayload` method which could be called multiple times. Each `initWithPayload` fires `ModelUpdated` event.
- Each has a `range` getter which returns either a text range inside parent stylesheet, or `NULL` in case of `CSSOM/User-Agent` origin.
That said, all `CSSSelectorList`, `CSSStyle` and `CSSMedia` extend the following base class.
```javascript
/**
* @extends {CSSObject}
* @param {?styleSheetId} styleSheetId
* @param {?TextRange} textRange
*/
CSSEditableModel = function(styleSheetId, textRange) {
this._styleSheetId = styleSheetId;
this._range = textRange;
}
CSSEditableModel.Events = {
RangeChanged,
ModelUpdated
}
CSSEditableModel.prototype = {
/**
* @return {?string}
*/
id: function() {
// @see CSSModel.textId below.
return CSSModel.textId(this._styleSheetId, this._range);
},
handleEdit: function(event) {
// This method is called for source-based models only.
console.asert(this._range);
console.assert(this._styleSheetId);
if (this._styleSheetId !== event.styleSheetId)
return;
var newRange = this._range.rebaseline(event);
if (!newRange) {
this.obsolete();
} else if (!newRange.equals(this._range)) {
this._range = newRange;
FIRE(RangeChanged);
}
},
/**
* @param {?CSSEditableModel} other
* @return {boolean}
*/
equals: function(other) {
// Do not forget to do a instanceOf check!
return false;
},
/**
* @protected
*/
initWithPayload: function(payload) {
FIRE(ModelUpdated);
},
/**
* @return {boolean}
*/
editable: function() {
return !!this._range && !!this._styleSheetId;
},
/**
* @return {!Promise.<boolean>}
*/
setText: function(newText) {
return Promise.resolve(false);
}
}
```
> **Note** every model overrides `initWithPayload` method.
### 3. CSSRule
`CSSRule` is just a container of `CSSStyle` and `CSSSelectorList` objects. It gets obsolete once either style or selector gets obsolete.
> **Note** As there is no `CSSStyleSheetHeader` for inline stylesheets, we store `styleSheetId` instead of direct links to `CSSStyleSheetHeader` objects.
> **FIXME** Should selector and style become obsolete as the rule does?
```javascript
/**
* @extends {CSSObject}
*/
CSSRule = function(cssModel, payload) {
this.styleSheetId = payload.styleSheetId;
this.origin = payload.origin;
this.style = cssModel.styleForPayload(payload.style);
this.selector = cssModel.selectorForPayload(payload.selector);
LISTEN(style, this.obsolete);
LISTEN(selector, this.obsolete);
}
```
### 4. CSSModel
The biggest class that manages all sources-based objects is `CSSModel`.
The class deduplicates models via the following methods:
- `selectorForPayload`
- `styleForPayload`
- `mediaForPayload`
> **NOTE** As there is a 1-to-1 relationship between `CSSRule` and `CSSSelector` objects, there's no need to deduplicate `CSSRule` objects.
The class handles all `CSSAgent` events.
The class provides the following methods for fetching:
- `matchedStylesForNode` : `MatchedStyles`
- `computedStyleForNode` : `ComputedStyle`
- `inlineStyleForNode` : `InlineStyle`
- `platformFontsForNode` : `PlatformFonts`
- `allMediaQueries` : `MediaList`
#### 4.1 Model Deduplication
The CSSModel deduplicates selectors, styles and medias. It stores them all in a map, referencing by id. The id of editable model is just its position in the source styles sheet.
```javascript
CSSModel = function() {
// CSSSelectorList, CSSMedia, CSSStyle
this._models = new Map();
}
CSSModel.textId = function(styleSheetId, range) {
if (!styleSheetId || !range)
return null;
return styleSheetId + ":" + range.toString();
}
CSSModel.prototype = {
// selectorForPayload and styleForPayload are the same.
ruleForPayload: function(payload) {
return this._modelForPayload(payload, CSSRule);
},
_modelForPayload: function(payload, ModelConstructor) {
var newModel = new ModelConstructor(this, payload);
if (!newModel.id())
return newModel;
var oldModel = this._models.get(newModel.id());
if (oldModel && oldModel.equals(newModel))
return oldModel;
if (oldModel && !oldModel.equals(newModel)) {
console.error("Old model exists but does not match!");
oldModel.obsolete();
this._models.delete(newModel.id());
}
this._models.set(newModel.id(), newModel);
return newModel;
},
}
```
#### 4.2 CSSAgent Event Handling
CSSModel handles all CSS events:
- it rebases text positions of editable models as some edits happen
- it removes rules once some were removed
- it adds and removes CSSStyleSheetHeader objects
```javascript
CSSModel = function() {
...
LISTEN(SelectorChanged, this._onModelEdited.bind(this, CSSSelectorList));
LISTEN(StyleChanged, this._onModelEdited.bind(this, CSSStyle));
LISTEN(MediaChanged, this._onModelEdited.bind(this, CSSMedia));
LISTEN(RuleAdded, this._onRuleAdded);
LISTEN(RuleRemoved, this._onRuleRemoved);
LISTEN(StyleSheetAdded, this._onStyleSheetAdded);
LISTEN(StyleSheetRemoved, this._onStyleSheetRemoved);
LISTEN(StyleSheetTextUpdated, this._onStyleSheetTextUpdated);
}
CSSModel.prototype = {
...
_onStyleSheetTextUpdated: function(event) {
// Remove all models from this styleSheet.
var models = new Map();
for (var model of this._models) {
if (model.styleSheetId === event.styleSheetId)
model.obsolete();
if (!model.isObsolete())
models.set(model.id(), model);
}
this._models = models;
},
_onStyleSheetRemoved: function(event) {
REMOVE_STYLE_SHEET_HEADER();
var models = new Map();
for (var model of this._models) {
if (model.styleSheetId === event.styleSheetId)
model.obsolete();
if (!model.isObsolete())
models.set(model.id(), model);
}
this._models = models;
},
_onRuleRemoved: function(event) {
// The rebase will obsolete all models with intersecting ranges.
this._rebaseModelRanges(event);
},
_onRuleAdded: function(event) {
// The rebase will obsolete all models with intersecting ranges.
this._rebaseModelRanges(event);
},
_rebaseModelRanges: function(event) {
var newModels = new Map();
for (var model of this._models) {
model.handleEdit(event);
if (!model.isObsolete())
newModels.set(model.id(), model);
}
this._models = newModels;
},
_onModelEdited: function(ModelClass, event) {
var textId = CSSModel.textId(event.styleSheetId, event.oldRange);
// Pull off edited model from map to avoid it be rebaselined.
var editedModel = this._models.get(textId);
this._models.delete(textId);
this._rebaseModelRanges(event);
if (!editedModel)
return;
if (!(editedModel instanceof ModelClass)) {
editedModel.obsolete();
return;
}
editedModel.initWithPayload(event.payload);
this._models.set(editedModel.id(), editedModel);
},
...
}
```
#### 4.3 Fetching methods
All the fetching methods return a specific descendant of `CSSObject`, which gets obsolete under specific conditions.
> **Example** `getComputedStyle` method returns `ComputedStyle` object which gets obsolete on any change of css or dom models.
The invalidation summary:
- `ComputedStyle` and `PlatformFonts` objects get obsolete once *any* css agent event happens, as well as any *domMutated* event.
- `MediaList` object gets obsolete once any of its medias get obsolete, or `StyleSheetAdded`.
- `matchedStyle` object gets obsolete once any of its rule objects get obsolete, or `CSStyleSheetAdded` gets fired, or `RuleAdded` gets fired, or `MediaQueryResultChanged` gets fired.
> **FIXME** when do `InlineStyle` object gets obsolete?
All fetching methods cache returned value per node until it gets obsolete. E.g. `getComputedStyleForNode`, **called with the same `nodeId`**, does the following:
1. On the first run, `getComputedStyleForNode` fetches payload from CSSAgent, constructs the `ComputedStyle` object and saves it internally.
2. As far as the `ComputedStyle` is not obsolete, the `getComputedStyleForNode` always returns the same `ComputedStyle` object without any round-trip to the backend.
3. As soon as the cached value gets obsolete, the `getComputedStyleForNode` will fetch a new value from the backend.
> **Note** This same-node caching behavior is implemented with the help of `CSSCachedObject` utility class.
Whenever `getComputedStyleForNode` **gets called with different `nodeId`'s**, it maintains caches for the last finite (e.g. 2) amount of `nodeId`'s.
> **Note** The across-nodeId caching is implemented via the map with constrain on amount of entries (with FIFO policy when dealing with oversizing)
### Appendix: CSSCachedObject
Consider you've got a `fetchCSSObject` method, which grabs some payload from backend and creates a `CSSObject`. (examples: `getComputedStyle`, `getMatchedStyles` etc).
You'd like to call the `fetchCSSObject` as little as possible, returning previous value until it is obsolete.
The `CSSCachedObject` implements this functionality.
```javascript
/**
* @param {function():Promise<CSSObject>} fetchCSSObject
*/
CSSCachedObject = function(fetchCSSObject) {
this._fetchAction = fetchCSSObject;
this._cssObject = null;
this._promise = null;
}
CSSCachedObject.prototype = {
/**
* @return {!Promise.<!CSSObject>}
*/
fetch: function() {
// If we have relevant data - return it.
if (this._cssObject && !this._cssObject.isObsolete())
return this._promise;
// If we have outdated data - clear caches.
if (this._cssObject && this._cssObject.isObsolete()) {
delete this._cssObject;
delete this._promise;
}
// If we don't have a fetching promise, then fetch.
if (!this._promise) {
this._promise = this._fetchAction()
.then(this._onSuccess)
.catch(this._onFail);
}
return this._promise;
},
_onSuccess: function(cssObject) {
this._cssObject = cssObject;
return cssObject;
},
_onFail: function(error) {
console.log("Error:" + error);
delete this._promise;
}
}
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment