Skip to content

Commit

Permalink
Change add(request) and addAll(requests) behavior
Browse files Browse the repository at this point in the history
 - Change add and addAll to reject if any of the responses are not in ok status
 - Automatically disallow opaque responses and opaque-redirect responses
  • Loading branch information
jungkees committed Mar 11, 2016
1 parent d66fc6e commit e2a6d18
Show file tree
Hide file tree
Showing 4 changed files with 11 additions and 6 deletions.
3 changes: 2 additions & 1 deletion spec/service_worker/index.bs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ spec: fetch; urlPrefix: https://fetch.spec.whatwg.org/
text: navigation request
text: network error; url: concept-network-error
text: non-subresource request
text: ok status; url: ok-status
text: opaque filtered response; url: concept-filtered-response-opaque
text: potential-navigation-or-subresource request
text: process response
Expand Down Expand Up @@ -2197,7 +2198,7 @@ spec: webidl; urlPrefix: https://heycam.github.io/webidl/
<li><a>Fetch</a> <var>r</var>.</li>
<li>To <a>process response</a> for <var>response</var>, run these substeps:
<ol>
<li>If <var>response</var>'s <a for="response">type</a> is <em>error</em>, reject <var>responsePromise</var> with a <code>TypeError</code>.</li>
<li>If <var>response</var>'s <a for="response">type</a> is <em>error</em>, or <var>response</var>'s <a>status</a> is not an <a>ok status</a>, reject <var>responsePromise</var> with a <code>TypeError</code>.</li>
<li>Else if <var>response</var>'s <a for="response">header list</a> contains a <a>header</a> <a for="header">named</a> `<code>Vary</code>`, then:
<ol>
<li>Let <var>varyHeaders</var> be the array containing the elements corresponding to the <a for="http">field-values</a> of the <a>Vary</a> header.</li>
Expand Down
6 changes: 4 additions & 2 deletions spec/service_worker/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@
<div class="head">
<p data-fill-with="logo"><a class="logo" href="http://www.w3.org/"> <img alt="W3C" height="48" src="https://www.w3.org/Icons/w3c_home" width="72"> </a> </p>
<h1 class="p-name no-ref" id="title">Service Workers Nightly</h1>
<h2 class="no-num no-toc no-ref heading settled" id="subtitle"><span class="content">Editor’s Draft, <time class="dt-updated" datetime="2016-03-10">10 March 2016</time></span></h2>
<h2 class="no-num no-toc no-ref heading settled" id="subtitle"><span class="content">Editor’s Draft, <time class="dt-updated" datetime="2016-03-11">11 March 2016</time></span></h2>
<div data-fill-with="spec-metadata">
<dl>
<dt>This version:
Expand Down Expand Up @@ -3271,7 +3271,7 @@ <h4 class="heading settled" data-level="5.4.4" id="cache-addAll"><span class="se
<li>
To <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#process-response">process response</a> for <var>response</var>, run these substeps:
<ol>
<li>If <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-type">type</a> is <em>error</em>, reject <var>responsePromise</var> with a <code>TypeError</code>.
<li>If <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-type">type</a> is <em>error</em>, or <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-status">status</a> is not an <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#ok-status">ok status</a>, reject <var>responsePromise</var> with a <code>TypeError</code>.
<li>
Else if <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-header-list">header list</a> contains a <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-header">header</a> <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-header-name">named</a> `<code>Vary</code>`, then:
<ol>
Expand Down Expand Up @@ -5543,6 +5543,7 @@ <h3 class="no-num no-ref heading settled" id="index-defined-elsewhere"><span cla
<li><a href="https://fetch.spec.whatwg.org/#navigation-request">navigation request</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-network-error">network error</a>
<li><a href="https://fetch.spec.whatwg.org/#non-subresource-request">non-subresource request</a>
<li><a href="https://fetch.spec.whatwg.org/#ok-status">ok status</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-filtered-response-opaque">opaque filtered response</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-request-origin">origin</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-header-parse">parsing</a>
Expand All @@ -5555,6 +5556,7 @@ <h3 class="no-num no-ref heading settled" id="index-defined-elsewhere"><span cla
<li><a href="https://fetch.spec.whatwg.org/#concept-request-request">request</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-response">response</a>
<li><a href="https://fetch.spec.whatwg.org/#skip-service-worker-flag">skip service worker flag</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-response-status">status</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-body-stream">stream</a>
<li><a href="https://fetch.spec.whatwg.org/#subresource-request">subresource request</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-fetch-terminate">terminate</a>
Expand Down
3 changes: 2 additions & 1 deletion spec/service_worker_1/index.bs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ spec: fetch; urlPrefix: https://fetch.spec.whatwg.org/
text: navigation request
text: network error; url: concept-network-error
text: non-subresource request
text: ok status; url: ok-status
text: opaque filtered response; url: concept-filtered-response-opaque
text: potential-navigation-or-subresource request
text: process response
Expand Down Expand Up @@ -2105,7 +2106,7 @@ spec: webidl; urlPrefix: https://heycam.github.io/webidl/
<li><a>Fetch</a> <var>r</var>.</li>
<li>To <a>process response</a> for <var>response</var>, run these substeps:
<ol>
<li>If <var>response</var>'s <a for="response">type</a> is <em>error</em>, reject <var>responsePromise</var> with a <code>TypeError</code>.</li>
<li>If <var>response</var>'s <a for="response">type</a> is <em>error</em>, or <var>response</var>'s <a>status</a> is not an <a>ok status</a>, reject <var>responsePromise</var> with a <code>TypeError</code>.</li>
<li>Else if <var>response</var>'s <a for="response">header list</a> contains a <a>header</a> <a for="header">named</a> `<code>Vary</code>`, then:
<ol>
<li>Let <var>varyHeaders</var> be the array containing the elements corresponding to the <a for="http">field-values</a> of the <a>Vary</a> header.</li>
Expand Down
5 changes: 3 additions & 2 deletions spec/service_worker_1/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1397,7 +1397,7 @@
<div class="head">
<p data-fill-with="logo"><a class="logo" href="http://www.w3.org/"> <img alt="W3C" height="48" src="https://www.w3.org/Icons/w3c_home" width="72"> </a> </p>
<h1 class="p-name no-ref" id="title">Service Workers 1</h1>
<h2 class="no-num no-toc no-ref heading settled" id="subtitle"><span class="content">Editor’s Draft, <time class="dt-updated" datetime="2016-03-10">10 March 2016</time></span></h2>
<h2 class="no-num no-toc no-ref heading settled" id="subtitle"><span class="content">Editor’s Draft, <time class="dt-updated" datetime="2016-03-11">11 March 2016</time></span></h2>
<div data-fill-with="spec-metadata">
<dl>
<dt>This version:
Expand Down Expand Up @@ -3200,7 +3200,7 @@ <h4 class="heading settled" data-level="5.4.4" id="cache-addAll"><span class="se
<li>
To <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#process-response">process response</a> for <var>response</var>, run these substeps:
<ol>
<li>If <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-type">type</a> is <em>error</em>, reject <var>responsePromise</var> with a <code>TypeError</code>.
<li>If <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-type">type</a> is <em>error</em>, or <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-status">status</a> is not an <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#ok-status">ok status</a>, reject <var>responsePromise</var> with a <code>TypeError</code>.
<li>
Else if <var>response</var>’s <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-response-header-list">header list</a> contains a <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-header">header</a> <a data-link-type="dfn" href="https://fetch.spec.whatwg.org/#concept-header-name">named</a> `<code>Vary</code>`, then:
<ol>
Expand Down Expand Up @@ -5311,6 +5311,7 @@ <h3 class="no-num no-ref heading settled" id="index-defined-elsewhere"><span cla
<li><a href="https://fetch.spec.whatwg.org/#navigation-request">navigation request</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-network-error">network error</a>
<li><a href="https://fetch.spec.whatwg.org/#non-subresource-request">non-subresource request</a>
<li><a href="https://fetch.spec.whatwg.org/#ok-status">ok status</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-filtered-response-opaque">opaque filtered response</a>
<li><a href="https://fetch.spec.whatwg.org/#concept-header-parse">parsing</a>
<li><a href="https://fetch.spec.whatwg.org/#potential-navigation-or-subresource-request">potential-navigation-or-subresource request</a>
Expand Down

36 comments on commit e2a6d18

@aliams
Copy link
Contributor

@aliams aliams commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

Is there a reason why we want to automatically disallow opaque responses? It appears that put() does not specifically disallow this, and so, you can use fetch() then put() to overcome this limitation of add(). In any case, this should probably be allowed due to the rationale given under 6.4. Cross-Origin Resources and CORS which states that we want to allow "Caches to fetch and cache off-origin items."

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

We changed add & addAll to reject on !ok responses, but we're not confident we can reveal that information on opaque responses.

We may reveal this in future, once we're happy with it security-wise.

@aliams
Copy link
Contributor

@aliams aliams commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

If you use mode no-cors in add(), you get a Response that is !ok. This effectively makes it impossible to cache items on a CDN using add(). However, you're able to work around this by using fetch() then put(). It sounds to me like the !ok text is overreaching perhaps since no-cors requests have !ok status?

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

You can cache items on a CDN using add if they have CORS headers. This is already required for fonts and JS modules.

We had two options:

"addAll caches responses as long as they're ok. It does not work for cross-origin no-cors"

Or

"addAll caches responses as long as they're ok. Unless they're cross-origin no-cors, in which case we'll cache them anyway, even though they may be HTTP 500 or whatever"

The latter seems much harder to explain, and failures may escape testing.

@aliams
Copy link
Contributor

@aliams aliams commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

It appears that cross-origin no-cors comes back with a status of 0 regardless (which is !ok). Why do we need to discern between statuses in that case?

In any case, why is this restriction not imposed on put() and it is for add()?

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

@jakearchibald

We had two options:

"addAll caches responses as long as they're ok. It does not work for cross-origin no-cors"

Or

"addAll caches responses as long as they're ok. Unless they're cross-origin no-cors, in which case we'll cache them anyway, even though they may be HTTP 500 or whatever"

The latter seems much harder to explain, and failures may escape testing.

FWIW I ran into this issue adding offline support to Lodash's website.

It was puzzling because docs like MDN Cache.put state

Often, you will just want to fetch() one or more requests, then add the result straight to your cache. In such cases you are better off just using Cache.add/Cache.addAll, as they are shorthand functions for one or more of these operations:

fetch(url).then(function (response) {
  return cache.put(url, response);
})

Or MDN Cache.add which states

The add() method of the Cache interface takes a URL, retrieves it and adds the resulting response object to the given cache. The add() method is functionally equivalent to the following:

fetch(url).then(function (response) {
  return cache.put(url, response);
})

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

It isn't a restriction, it's a feature. The feature is that addAll will reject if one or more of the responses is !ok. We made this change because this is what developers were expecting with this API.

With put, you pass in the response directly. You may have created it yourself, you may be creating a request-response mismatch. Since it's the lower level version, we assume you're checking the response yourself, and let you store whatever you want, even if it's a 404.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Some APIs already expose !ok to some extent, such as AppCache and <object>. If we figure out that's fully equivalent to exposing response.ok on opaque responses, we will expose it. Then we can make addAll work with no-cors and still reject on 404s etc.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@jdalton seems like MDN needs updating.

This was a potentially backwards incompatible change we (carefully) made following developer feedback. Understandable that MDN is out of sync.

@addyosmani
Copy link

Choose a reason for hiding this comment

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

cc @jpmedley on updating the MDN content now that it is out of sync with the change post-feedback.

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

@jakearchibald

It isn't a restriction, it's a feature.

I donno...

It seems to like fetch + cache.put is the way to go.

The gist of the MDN description is correct. I did just...

want to fetch() one or more requests, then add the result straight to [the] cache.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@jdalton throwing errors can be a feature. What we were doing before was arguably failing silently.

With put you put the response into the cache. If you can't see into the response, it's easier to understand that you're blindly caching it, and that comes with risks. It may be an HTTP 500 that will break all your demos.

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

@jakearchibald

It may be an HTTP 500 that will break all your demos.

AFAIK it's the only way to do it. I couldn't use cache.add because it failed straight away.
That left fetch+put as a way to get it working for things like cdns and other external resources.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Yep. Hopefully those CDNs will start using CORS so you can reliably cache from them. They'll have to if they want to serve JS modules.

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

@jakearchibald
So is fetch+put still going to be an option? Or is the idea that service workers are intended to be broken/unusable for cdns/external resources until all use CORS?

The appeal of APIs like add was that I could fetch() one or more requests, then add the result straight to the cache.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@jdalton
Like I said in comments above, fetch+put lets you store opaque responses today, and will continue to do so.

addAll no longer does, because we added a feature so it fails on !ok responses like 404.

addAll may allow opaque responses in future, once the due diligence is done around security.

@mkruisselbrink
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be an option to add an "options" parameter to put/putAll/add/addAll to let websites decide for all these methods if they want !ok to be working or not? Might still be confusing if the default for different methods would be different...

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

Cool. The issues raised by Ali and myself weren't about using addAll (it was add).
Do your comments apply to add too?

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Yes

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@mkruisselbrink yeah, we discussed this. It's still possible, but I'd rather we just exposed "ok" on opaque responses.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@jdalton add = request => addAll([request])

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

@jakearchibald

add = request => addAll([request])

Yep, yep. Why wasn't it addAll => map(requests, add)?

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

it's an atomic operation. If one thing fails, nothing goes in, so it's more than a Promise.all.

We're going to explain this stuff better in future with cache transactions #823

@aliams
Copy link
Contributor

@aliams aliams commented on e2a6d18 Aug 22, 2016

Choose a reason for hiding this comment

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

Thank you @jakearchibald. To summarize:

  • add() is meant to be a reliable way to cache responses - on the other hand put() does not have the same guarantees so you can put any Response (even with not ok status code e.g. cross-origin no-cors)
  • the MDN article will be updated to reflect that add() is not the equivalent of fetch() + put()
  • we will work towards a secure solution that allows opaque responses to have an ok response to support add() for cross-origin no-cors in the future

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Yep! Allowing ok on opaque responses basically means proving the web is already broken in this regard 😀

@wanderview
Copy link
Member

Choose a reason for hiding this comment

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

Clearly the documentation could be improved, but we did try to update it. See:

https://bugzilla.mozilla.org/show_bug.cgi?id=1244764#c19

And the "Exception" section in these two pages:

https://developer.mozilla.org/en-US/docs/Web/API/Cache/add
https://developer.mozilla.org/en-US/docs/Web/API/Cache/addAll

Personally I'd be more inclined to remove add() and addAll() rather than try to make them magically work better for opaque responses. In hindsight I think it was a mistake to try to add a convenience fetch+put operation into the platform so soon.

I also think people should probably avoid opaque responses in any kind of cache-first scenario. You just have no way to tell if the resource loaded or not. If you want to use a cache-first model you really need to get the resource served with CORS.

@NekR
Copy link

@NekR NekR commented on e2a6d18 Aug 23, 2016

Choose a reason for hiding this comment

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

> Some APIs already expose !ok to some extent, such as AppCache and <object>. If we figure out
> that's fully equivalent to exposing response.ok on opaque responses, we will expose it. Then we can 
> make addAll work with no-cors and still reject on 404s etc.

I knows FB's API which returns OK when user not logged and not OK when logged. I don't think it makes sense to add another way to check this.

The appeal of APIs like add was that I could fetch() one or more requests, then add the result straight to the cache.

Well, you can add results straight to the cache with put(), just write a little bit of a code.


Basically, you just caught into addAll caching 404/500 responses. Imagine that addAll won't have this change and restriction and so you probably wouldn't even think that CDN may return an error and what it will be cached. But CDN will in some day, because of network problems, CDN down, etc.

And then bad happens, your users get cached wrong response, website isn't working and ServiceWorker update doesn't help because you are probably caching static (CDN) resources in a separate cache which doesn't update (why would it, those assets are static after all). And then you probably don't know what to do, you don't know how to debug it and you are simply going to complain about ServiceWorker into the Twitter, this repo, etc.

Is that better? I don't think so.

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 23, 2016

Choose a reason for hiding this comment

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

Thanks for the details @jakearchibald!

I updated my caching technique (updated again with @wanderview's feedback) to attempt cors first with fetch+put, then fallback to no-cors with fetch+put and things work. I found a Chrome bug to boot.

@wanderview
Copy link
Member

Choose a reason for hiding this comment

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

@jdalton Just FYI, you are also running the risk of getting incoherently cached resources. It seems your site uses non-unique URL names with modest http cache max-ages. This means you could possibly pull some http cached resources and some non-http cached resources in your install event.

This is something @jeffposnick pointed out to me here:

https://twitter.com/jeffposnick/status/671451613224001538

@wanderview
Copy link
Member

@wanderview wanderview commented on e2a6d18 Aug 23, 2016

Choose a reason for hiding this comment

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

fetch(url).then(function (response) {
  return cache.put(url, response);
})

I updated this example in MDN to reflect the response.ok() check.

Edit: Make that response.ok. Computers are hard.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, max-age on mutable content is usually a mistake https://jakearchibald.com/2016/caching-best-practices/

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 23, 2016

Choose a reason for hiding this comment

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

@wanderview

[...] with modest http cache max-ages.

@jakearchibald

Yeah, max-age on mutable content is usually a mistake

I believe it's a gh-pages thing.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

@jdalton careful with using .catch() for logging, it means you've handled the error. Promises are async representations of try/catch, so you've written something like:

try {
  whatever();
}
catch (err) {
  console.log("It failed");
}
console.log("It worked");

In the above, "It worked" will always be logged, since you handle the error. You probably want to re-throw the error if you're catching purely for logging.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yeah, gh-pages doesn't let you change this (I call it out in the article, but doesn't look like they'll change).

@jdalton
Copy link
Member

@jdalton jdalton commented on e2a6d18 Aug 23, 2016

Choose a reason for hiding this comment

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

careful with using .catch() for logging, it means you've handled the error.

Yep, I know.

You probably want to re-throw the error if you're catching purely for logging.

Naw. For my use offline caching is pure bonus. I don't care to handle the error, there's no dummy content or some such to fallback to, just having something logged is enough. From my earlier example you can see even with just logging there's plenty of indicators in the console.

@jakearchibald
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, apologies if that was patronising - I've just seen that done in error a lot.

Please sign in to comment.