WICG / container-queries

Other
91 stars 10 forks source link

Proposal for a slightly different syntax and function #3

Open ausi opened 9 years ago

ausi commented 9 years ago

In https://github.com/ResponsiveImagesCG/container-queries/issues/2#issuecomment-121281825 I posted an idea about a different syntax. It looks like:

.child:container(min-width: 150px) {
    color: red;
}

And the nearest qualified ancestor is selected as the container to run the query against by the browser.

As I put more thoughts into this I got an idea of how the implementation issue (jumping between compute style and layout) could possibly be solved.

If I’m right the browser computes the styles by traversing the DOM tree from top to bottom. In this process it could already calculate and store the width if it knows that it doesn’t depend on its descendants. If it then reaches an element with a container query rule it already knows what the right container is – it’s the nearest ancestor for which it was able to calculate a width – and which width it has. So it should be possible to resolve the container queries without doing the layout process.

It may be that I’m totally wrong with my assumptions about browser internals, but it would be great if it is implementable this way.

If someone is interested in this idea I also wrote a prolyfill and a blog post about this version of container queries.

ausi commented 9 years ago

To better describe the idea for the browser implementation here is an example:

Lets go with a simple example DOM:

<html>
    <body>
        <div class="container">
            <div class="child"></div>
        </div>
    </body>
</html>

And the following CSS:

body {
    padding: 10px;
}
.container {
    float: left;
}
.child:container(min-width: 150px) {
    background: green;
}

And lets assume a viewport of 800x600.

In the compute style step the browser would do the following:

  1. Starting with the viewport as a kind of a root node and store the viewport width as the container width for this node. The root node has container-width = 800 stored now.
  2. Start traversing the DOM tree
  3. Matching the element html against all style rules which results in display: block; width: auto;. With this computed style we know that this element doesn’t depend on its descendants and we can inherit the container width from the parent node. So we store container-width = 800 for the html node.
  4. Matching the element body against style rules resulting in display: block; width: auto; padding-left: 10px; padding-right: 10px;. With this computed style we know that this element doesn’t depend on its descendants and we can again inherit the container width from the parent node. This time there is also a padding on the element, which we have to taken into account. So we store container-width = 780 for the body node.
  5. Matching the element div.container against style rules resulting in display: block; width: auto; float: left;. Now we have an element whose width does depend on its descendants so we have to store something like container-width = not-applicable.
  6. Matching the element div.child against style rules and hitting a rule with a container query in it. To determine if this rule matches we check the container-width of the parent node, which is not-applicable, so we go to the next parent to ask for container-width and get the value 780 back. Now we match 780 against min-width: 150px which matches and so the rule matches. The computed style for this element is now background: green;.
  7. Finished traversing the DOM tree. All styles are computed now and we can go to the layout step.
BenjaminPoulain commented 9 years ago

Ok, that makes sense.

You do need to lay out twice. You would need to split the layout algorithms in two: a first pass only doing the simplest size computation top-down for independent properties only, followed by a second pass doing dependent properties after you do your second style recalc.

I am afraid the model is a bit complicated. It won't be easy to understand which parts of a document is viewport dependent, and which parts are independent.

I quite like it to be honest. It would be hard to implement but it is a step in the right direction.

ausi commented 9 years ago

Thank you for the great feedback!

So you would suggest first doing a style calculation without container queries, then a descendants-independent layout algorithm, then a style calculation including container queries and then the second pass of the layout algorithm as the last step?

Wouldn’t it be better for the performance to do the first pass of layout directly in the style calculation step?

BenjaminPoulain commented 9 years ago

Layout is incredibly complicated. Having a internally consistent style during the entire layout is a useful simplifying assumption.

I am not even sure you could do layout with no style for all descendents but I can't think of an example against that at the moment.

Style collection and style resolution are pretty self contained. They are not particularly efficient but layout is an order of magnitude worse. I would not be too concerned about an extra style collection && resolution step if-and-only-if the total layout time remains unchanged (or is improved).

ausi commented 9 years ago

The problem I see with splitting the style calculation and the first pass layout is that they have to run multiple times if elements with container queries are nested. In the worst case one time per nesting level.

Layout is incredibly complicated, but AFAIK the parts that would be needed in the „first pass“ for the container queries should be much simpler. E.g. the direction for the computation is strict top down, it’s never necessary to know something about the child nodes to calculate the „container-width“. As you can see in step five of the example above, in cases where the full layout algorithm depends on descendants, the „first pass“ layout just computes not-applicable.

davatron5000 commented 9 years ago

Pardon my ignorance, were we to explicitly define container elements would that help avoid or speed up the second evaluation pass? Could a property trigger those children to be added to a collection that could then be evaluated?

body {
    padding: 10px;
}
.container {
    float: left;
    layout: container;
}
.child:container(min-width: 150px) {
    background: green;
}
ausi commented 9 years ago

In my example above the body element is used as the container for the query. As I read it again now, I should’ve named it .parent instead of .container.

The problem with explicitly defined containers is that the size of them may depend on its descendants. That the „right“ container is chosen by the browser is important IMO to solve the recursion issue and preventing CSS authors from mistakes.

The CSS code you posted would result in a recursion if you add some dimensions:

body {
    padding: 10px;
}
.container {
    float: left;
    layout: container;
}
.child {
    width: 200px;
}
.child:container(min-width: 150px) {
    background: green;
    width: 100px;
}

For a complete demo of the recursion problem with the current syntax, you can take a look at ResponsiveImagesCG/cq-demos#2.

davatron5000 commented 9 years ago

Okay I think I'm understanding it all more. This makes a lot of sense.

  1. Traverse the DOM
  2. Store container-width = px-value || not-applicable
  3. Collect .child elements with Container Queries.
  4. Bubble up until container-width !== not-applicable, evaluate Container Query.
  5. Calculate styles based on match.
  6. Render styles.

Q: What about inheritance? Would the CQ evaluate on the inherit width: 100% on a un-floated .parent?

div.parent {
}

.child:container(min-width: 150px) {
  width: 100px;
}
ausi commented 9 years ago

Yes it would. In my example above html and body are inheriting from their parents, they behave just like two nested divs would.

A .parent with float: left; width: 50%; would also be fine for the container query, because it doesn’t depend on the size of its descendants then. Determining if an element depends on descendants or not isn’t very trivial, but IMO browsers already know the rules to compute it.

You can take a look at the functions isFixedSize and isIntrinsicSize of my prolyfill to see how it works there. It isn’t complete but handles the usual cases already.

ausi commented 9 years ago

If someone is interested: I posted an update on my proposal and added a demo page for the prolyfill. The prolyfill script is now tested and pretty stable, so it should be ready to play around with it: https://github.com/ausi/cq-prolyfill.

maxhoffmann commented 9 years ago

Have you thought about calling it parent instead of container? As the DOM is a tree I think parent is a better fit:

.component:parent(min-width: 150px)

ausi commented 9 years ago

IMO :parent() could be confusing for this proposed version, because the query doesn’t always target the direct parent element.

What about :context()? Because we are querying the “context” the element is surrounded by.

A word which would make the selector readable as a sentence would be great too IMO, like it is with :has() or :matches().

maxhoffmann commented 9 years ago

In my opinion :has and :matches read as if the condition applies to the element itself, which it doesn't.

In CSS :context is a term one might associate with the z-index context. Despite that I feel like something can’t be surrounded by a context.

:container already implies some sense of layout to me, so it does fit better in this sense. To me it still implies a sense of limiting dimensions, which might not be the case. Also it suffers from the same problem you mention that the query might not always target the direct "container".

Isn't an element's query condition always retrieved from its parent node? The parent might have inherited the queries value but it still has the information, doesn't it?

ausi commented 9 years ago

Isn't an element's query condition always retrieved from its parent node? The parent might have inherited the queries value but it still has the information, doesn't it?

This is an implementation detail IMO and could be different in various browsers.

As I’m no native speaker I don’t really know what fits better, I would be OK with any of container, parent or context. Maybe something more verbose would be fine too like :surrounded-by().

But I think its a bit early for discussions about the correct name for container queries. We should check first if the proposed functionality is usable for CSS authors and implementable for browsers. As @BenjaminPoulain already mentioned “I quite like it to be honest. It would be hard to implement but it is a step in the right direction.” I’m optimistic for the browser side and my current intention is to get CSS authors to play around with the prolyfill.

stefanklokgieters commented 8 years ago

Is there any news regarding making a formal proposal towards W3C? Is there any news regarding browsers developers integrating this technology in their products? can see a lot of benefits using this functionality.

Thanks!

ausi commented 8 years ago

@stefanklokgieters IMO before making a proposal towards W3C, we need to know from browser makers if this version of container queries is any better than the others and if it is possible to implement it in a performant way.

davatron5000 commented 8 years ago

Agree: Let's not get jammed up on syntax right now. Let's stay focused on the ideal functionality.

After many months of mulling this over, I think this is a pretty fool proof way to avoid the infinite recursion problem. I think we'd need feedback on:

I'd be happy to start soft-balling this to browser people for feedback on this technique.

In the meantime maybe we should get some prolyfill performance stats on 1, 10, 100, 1000 container -queries being applied? Even if it's just JavaScript we can start getting an idea of time/memory footprint.

davatron5000 commented 8 years ago

Feedback from someone on the Chrome team:

The multiple-layout issue is real. Would simplify things somewhat if parents were specific (ID? Simple selector?) so you could match them as you walk down and remember them in the first traversal...that might let you shortcut in many cases (e.g., when a parent of your parent sizes it definitively)

Overall the need for isolated module styling was understood. This 2-pass layout thing seems like an issue but will keep asking around for feedback.

I'm also putting together a little explainer doc so it's easier for people to catch up on the multi-year conversation.

ausi commented 8 years ago

@davatron5000 Thanks for your help!

In the meantime maybe we should get some prolyfill performance stats on 1, 10, 100, 1000 container -queries being applied? Even if it's just JavaScript we can start getting an idea of time/memory footprint.

I will look into that and report my findings here.

d6u commented 8 years ago

A React implementation perf example using rAF:

Looks like the 1000 one suffers a lot when resizing the window. But scroll performance is OK. But all of the demos are simple, might not be what you wanted.

Implementation details see https://github.com/d6u/react-container-query/blob/master/src/createContainerQueryMixin.js#L27-L54

ausi commented 8 years ago

I tested the prolyfill with a simple container query:

div:container(width > 500px) {
    background: green;
}

The result:

# of Elements Initialize Time Resize Time
1 8.20ms 1.26ms
10 7.86ms 1.82ms
100 9.36ms 7.23ms
1,000 43.35ms 30.50ms
10,000 304.72ms 306.23ms

I also tested different nesting levels but that doesn’t change the result that much.

d6u commented 8 years ago

@ausi A dumb question, how did you measure the resize time and initial time? Any doc/code I can learn from?

ausi commented 8 years ago

@d6u I wrote a quick script to measure the speed, you can take a look at it here: https://gist.github.com/ausi/0f30d7568d2f93c04fa3

jonathantneal commented 8 years ago

Great work, @ausi. This looks fantastic.

dbaron commented 8 years ago

I think limiting queries to elements that have 'contain: strict' avoids many of the theoretical problems (although it would still be a substantial amount of work to implement).

If you don't have that limitation, then you have the problem that the size of the element (which you're querying on) can be influenced by the contents of the element, which can in turn be influenced by whether the query matches. https://github.com/ResponsiveImagesCG/container-queries/issues/3#issuecomment-128185440 suggests this is doable with two passes, but I'm not quite sure I see how that works for handling of dynamic changes. The fundamental problem with handling dynamic changes is that we want a small change to content (e.g., adding a character of text) to have a small cost to re-layout. If you have an algorithm that's fundamentally two-pass, then you either (1) need to re-layout everything back to the first pass state and then re-layout everything again back to the second pass state, or (2) maintain separate data structures of the first pass and second pass states. For nesting of elements that use container queries, (1) would yield an exponential cost in time and (2) would yield an exponential cost in memory usage; I don't think either is likely to be acceptable as a performance/memory characteristic of the Web platform. (That said, I'm not sure that flexbox and/or grid haven't made this mistake -- which may be related to performance problems people are having with flexbox.) (How does your JS implementation handle these cases?)

Even with the limitation to elements with 'contain: strict', implementations would need to restructure styling and layout algorithms to take advantage of strict containment (and wait to do styling on the descendants of an element with strict containment until after the container has been laid out).

ausi commented 8 years ago

@dbaron thank you for taking a look at it!

If I'm understanding contain: strict correclty, a container would have to have both width and height not depend on its contents, which would eliminate most use cases for container queries IMO.

If you don't have that limitation, then you have the problem that the size of the element (which you're querying on) can be influenced by the contents of the element, which can in turn be influenced by whether the query matches.

My idea is to let the browser select which element the query gets matched against (the nearest qualified ancestor), so that a recursion cannot happen. But this selection only requires one dimension to be not influenced by the contents, the other (non-queried) dimension may still depend on the contents. Maybe adding a dimension to the contain property would help here, like contain: layout-width or contain: layout-height?

How does your JS implementation handle these cases?

  1. My implementation lets the browser first do a complete layout with the container queries of the last layout applied (or none applied if it's the first one).
  2. Then it traverses through a tree of all elements that have a style rule with :container(...) and evaluates the queries of them (against their nearest qualified ancestor).
  3. If one or more queries produce a different result than in the last layout, the styles are changed, all children of those elements in the tree get marked as dirty, the browser does another layout and we continue with step 2, but only with the dirty elements and their descendants this time.

For small changes in the document, step 2 wouldn't find any changed container queries and no additional layout is needed.

For most changes that trigger a container query, one second layout is needed.

In the worst case the number of additional layouts is as high as the nesting level of container query elements.

dbaron commented 8 years ago

So saying the browser can select which element the query gets matched against isn't really a useful answer. Which element will it actually select, and how will it handle that?

I can see that you'd want auto-sizing in one dimension, though.

However, I don't think doing layout containment in only a single dimension is sufficient. It's not clear to me there's a sensible way to benefit from that across all of CSS's layout algorithms (e.g., flex, grid), some of which are rather complex. Though maybe they've managed to preserve some clever invariants, but I doubt it.

So one problem with the algorithm that you describe in https://github.com/ResponsiveImagesCG/container-queries/issues/3#issuecomment-185979829 is that it's dependent on what the previous layout was. We generally try to avoid making layout algorithms work such that you can get a different layout for the same DOM depending on the sequence of mutations that led to that DOM (though this may not be quite true for non-overlay scrollbars). But perhaps that's an ok invariant to break. It is scary, though, since it will lead to bugs where a site has a different display depending on the ordering of its incremental loading process (since incremental loading is effectively a sequence of dynamic changes) -- so effectively race conditions in layout.

This also assumes that you've set things up so that the size (in the one dimension) of the qualified ancestor can't be influenced by the selectors. This is nontrivial; it probably requires most aspects of style containment, and it's far from clear to me that it's generally true across table layout, flex layout, and grid layout if you introduce something like single-axis layout containment. For some container query algorithms, it might be ok if this sometimes failed in edge cases. But combined with an algorithm like the one you described that's dependent on the previous state, it's pretty bad, since you could get into cases where each successive re-layout produces a one of two alternating states (or, with multiple queries, possibly even more complicated state machines), which is probably an even worse race condition. (The comparison here is against an algorithm that is expected to compute everything from an initial state right up front, but this has the problems I described in https://github.com/ResponsiveImagesCG/container-queries/issues/3#issuecomment-185951645 .)

ausi commented 8 years ago

So saying the browser can select which element the query gets matched against isn't really a useful answer. Which element will it actually select, and how will it handle that?

For a width-query it will select the nearest ancestor whichs width doesn't depend on its contents. My JS impelementation currently traverses the DOM tree up until it finds an element with a fixed width, from this element it then traverses the DOM tree back down as long as the elements widths depend on their parent. It does that by checking the style of the elements against some simple rules, the rules for grid and flexbox are not (yet) implemented.

One issue with this algorithm are scrollbars, which could change the inner width of an element depending on contents.

So one problem with the algorithm that you describe in #3 (comment) is that it's dependent on what the previous layout was.

That was the most performant way I found for the JS implementation because I cannot hook into the layout process of the browser. An implementation in the browser may work differently.

tigt commented 8 years ago

As far as baby steps go, would something like <iframe autosize="vertical"> be easier to implement? The request overhead doesn't seem so bad with HTTP/2 on the horizon.

ausi commented 8 years ago

@tigt AFAIK it would be much easier and it is already implemented in iOS I think. Auto-resize iframes are currently being discussed in the www-style mailing list: https://lists.w3.org/Archives/Public/www-style/2016Mar/0198.html.

hnqso commented 8 years ago

@ausi good stuff! I like the idea about :container as pseudo-class but I'm wondering why have you chose to go with operators in the query rather keep it simple with max-width|min-width with a similar api like media queries?

ausi commented 8 years ago

@henriquea Thanks! There is an issue about adapting the syntax to the syntax of media queries: ausi/cq-prolyfill#8