Migrate to streaming app shell via serviceworkers (#5020) [deploy]

* Initial work for streaming app shell way of using serviceworkers

* Close in on serviceworker adjustment finalization

* Add docs and loading indicator

* Remove useless code

* Don't run on API

* Update app/javascript/contentDisplayPolicy/hideBlockedContent.js

* Fix small details

* Don't run serviceworker.js code in test env

* Fix JS in serviceworker

* Move private keyword to proper place in async controller

* Change shell_version to HEROKU_SLUG_COMMIT

* Update caching config

* Add test for async_info/shell_version
This commit is contained in:
Ben Halpern 2019-12-16 12:21:33 -05:00 committed by GitHub
parent 70e1ffbbc3
commit 630c1b7425
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 404 additions and 342 deletions

View file

@ -267,6 +267,7 @@ body.trusted-status-true .trusted-visible-block {
color: white;
display: inline-block;
margin-bottom: 0px;
border-radius: 3px;
}
h2 {
font-size: 22px;
@ -281,4 +282,34 @@ body.trusted-status-true .trusted-visible-block {
opacity: 0.9;
}
}
form button {
background: $red;
color: white;
font-size: 1.5em;
border: 0px;
border-radius: 3px;
padding: 6px 12px;
}
}
.app-shell-loader {
position: absolute;
top: 200px;
text-align: center;
left: 0;
right: 0;
opacity: 0;
animation: loading-fadein 1.5s;
animation-delay: 0.6s;
font-size: 1.2em;
z-index: -1;
}
.base-background-color {
@include themeable(background, theme-background, $lightest-gray);
}
@keyframes loading-fadein {
from { opacity: 0; }
to { opacity: 0.6; }
}

View file

@ -670,6 +670,13 @@
.signout_confirm-wrapper {
padding: calc(15% + 50px) 2% 30%;
text-align: center;
form button {
font-size: 1.5em;
padding: 8px 15px;
border: 0px;
border-radius: 3px;
margin-top: 15px;
}
}
.org-widget-preview .primary-sticky-nav {

View file

@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
def after_sign_in_path_for(resource)
return "/onboarding?referrer=#{request.env['omniauth.origin'] || 'none'}" unless current_user.saw_onboarding
request.env["omniauth.origin"] || stored_location_for(resource) || "/dashboard"
(request.env["omniauth.origin"] || stored_location_for(resource) || "/dashboard") + "?signin=true" # This signin=true param is used by frontend
end
def raise_banned

View file

@ -1,6 +1,7 @@
class AsyncInfoController < ApplicationController
include Devise::Controllers::Rememberable
# No pundit policy. All actions are unrestricted.
before_action :set_cache_control_headers, only: %i[shell_version]
def base_data
flash.discard(:notice)
@ -29,6 +30,13 @@ class AsyncInfoController < ApplicationController
end
end
def shell_version
set_surrogate_key_header "shell-version-endpoint"
# shell_version will change on every deploy. *Technically* could be only on changes to assets and shell, but this is more fool-proof.
shell_version = ApplicationConfig["HEROKU_SLUG_COMMIT"]
render json: { version: Rails.env.production? ? shell_version : rand(1000) }.to_json
end
def user_data
Rails.cache.fetch(user_cache_key, expires_in: 15.minutes) do
{

View file

@ -0,0 +1,13 @@
class ShellController < ApplicationController
layout false
def top
@shell = true
render partial: "top"
end
def bottom
@shell = true
render partial: "bottom"
end
end

View file

@ -15,38 +15,6 @@ module ApplicationHelper
end
end
def core_pages?
%w[
articles
podcast_episodes
events
tags
registrations
users
pages
chat_channels
dashboards
moderations
videos
badges
stories
comments
notifications
reading_list_items
html_variants
classified_listings
credits
partnerships
pro_memberships
].include?(controller_name)
end
def render_js?
article_pages = controller_name == "articles" && %(index show).include?(controller.action_name)
pulses_pages = controller_name == "pulses"
!(article_pages || pulses_pages)
end
def title(page_title)
derived_title = if page_title.include?(ApplicationConfig["COMMUNITY_NAME"])
page_title

View file

@ -88,6 +88,9 @@ export default class Chat extends Component {
channel => `open-channel-${channel.chat_channel_id}`,
);
setupObserver(this.observerCallback);
if (!window.currentUser) {
window.currentUser = JSON.parse(document.body.dataset.user)
}
this.subscribePusher(
`private-message-notifications-${window.currentUser.id}`,
);

View file

@ -2,8 +2,8 @@ export default function hideBlockedContent() {
const contentUserElements = Array.from(
document.querySelectorAll('div[data-content-user-id]'),
);
/* eslint-disable-next-line no-undef */
const blockedUserIds = userData().blocked_user_ids;
const user = userData(); //global var
const blockedUserIds = user ? user.blocked_user_ids : [];
const divsToHide = contentUserElements.filter(div => {
const { contentUserId } = div.dataset;

View file

@ -1,5 +1,3 @@
<%= javascript_include_tag "application" %>
<div class="container delete-confirm">
<h4><%= @article.title %></h4>
<h1>Are you sure you want to delete this article?</h1>
@ -11,8 +9,7 @@
</a>
instead?
</h2>
<h2>
<a class="delete-link" data-no-instant rel="nofollow" data-method="delete" href="/articles/<%= @article.id %>">DELETE</a>
</h2>
<%= form_tag "/articles/#{@article.id}", method: :delete do %>
<button class="cta">DELETE</button>
<% end %>
</div>

View file

@ -25,7 +25,6 @@
<% end %>
<%= javascript_pack_tag "homePage", defer: true %>
<% params.delete(:i) %>
<% cache("main-stories-index-#{params}-#{user_signed_in?}", expires_in: 3.minutes) do %>
<div class="home" id="index-container"
data-params="<%= params.to_json(only: %i[tag username q]) %>" data-which="<%= @list_of %>"

View file

@ -1,7 +1,6 @@
<%= content_for :page_meta do %>
<%= render "articles/tags/meta" %>
<% end %>
<% params.delete(:i) %>
<% expiry_minutes = params[:timeframe].blank? || params[:timeframe] == "latest" ? 4 : 20 %>
<% flag = true %>
<% cache("tag-stories-index-#{params}-#{flag}-#{@tag_model.updated_at}", expires_in: expiry_minutes.minutes) do %>

View file

@ -12,7 +12,7 @@
instead?
</h2>
<h2>
<a class="delete-link" data-no-instant rel="nofollow" data-method="delete" href="/listings/<%= @classified_listing.id %>">DELETE</a>
</h2>
<%= form_tag "/listings/#{@classified_listing.id}", method: :delete do %>
<button class="cta">DELETE</button>
<% end %>
</div>

View file

@ -1,5 +1,3 @@
<%= javascript_include_tag "application" %>
<div class="container delete-confirm">
<h4><%= truncate(strip_tags(@comment.processed_html.html_safe), length: 80) %></h4>
@ -7,8 +5,8 @@
<h2>You cannot undo this action, perhaps you just want to
<a data-no-instant href="<%= @comment.path %>/edit">EDIT</a> instead?</h2>
<h2>
<a class="delete-link" data-no-instant rel="nofollow" data-method="delete" href="/comments/<%= @comment.id %>">DELETE</a>
</h2>
<%= form_tag "/comments/#{@comment.id}", method: :delete do %>
<button class="cta">DELETE</button>
<% end %>
</div>

View file

@ -30,7 +30,7 @@
Key Links
</div>
</a>
<a href="/signout_confirm" data-no-instant id="last-nav-link">
<a href="/signout_confirm" id="last-nav-link">
<div class="option">
Sign Out
</div>

View file

@ -1,5 +1,10 @@
<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{core_pages?}_#{@tags_index}_#{@reading_list_items_index}_#{@history_index}_#{ApplicationConfig['HEROKU_SLUG_COMMIT']}_#{user_signed_in?}", expires_in: 8.hours do %>
<% if @story_show %>
<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{@tags_index}_#{@reading_list_items_index}_#{@history_index}_#{ApplicationConfig['HEROKU_SLUG_COMMIT']}_#{user_signed_in?}_#{@shell}", expires_in: 8.hours do %>
<% if @shell %>
<style>
<% Rails.application.config.assets.compile = true %>
<%= Rails.application.assets["minimal.css"].to_s.html_safe %>
</style>
<% elsif @story_show %>
<style>
<% Rails.application.config.assets.compile = true %>
<%= Rails.application.assets["scaffolds.css"].to_s.html_safe %>
@ -100,9 +105,7 @@
<style>
<%= Rails.application.assets["badges.css"].to_s.html_safe %>
</style>
<% elsif core_pages? %>
<%= stylesheet_link_tag "minimal", media: "all" %>
<% else %>
<%= stylesheet_link_tag "application", media: "all" %>
<%= stylesheet_link_tag "minimal", media: "all" %>
<% end %>
<% end %>

View file

@ -1,110 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<% title = yield(:title) %>
<title><%= title || "#{ApplicationConfig['COMMUNITY_NAME']} Community" %></title>
<% if internal_navigation? %>
<style>
.universal-page-content-wrapper {
visibility: hidden;
}
</style>
<% else %>
<meta name="last-updated" content="<%= Time.current %>">
<meta name="user-signed-in" content="<%= user_signed_in? %>">
<meta name="algolia-public-id" content="<%= ApplicationConfig["ALGOLIASEARCH_APPLICATION_ID"] %>">
<meta name="algolia-public-key" content="<%= ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY %>">
<meta name="environment" content="<%= Rails.env %>">
<%= render "layouts/styles" %>
<style>
.home {
overflow: hidden;
position: relative;
text-align: center;
min-height: 440px;
margin: auto;
max-width: 1250px;
}
@media screen and (min-width: 950px) {
.home {
margin-top: 26px;
}
}
</style>
<%= javascript_pack_tag "manifest", defer: true %>
<%= javascript_pack_tag "vendor", defer: true %>
<%= javascript_pack_tag "Search", defer: true %>
<% if core_pages? %>
<%= javascript_include_tag "base", defer: true %>
<% if user_signed_in? %>
<%= javascript_pack_tag "onboardingRedirectCheck", defer: true %>
<%= javascript_pack_tag "contentDisplayPolicy", defer: true %>
<% end %>
<% end %>
<% if !core_pages? %>
<%= stylesheet_link_tag "application", media: "all" %>
<% if render_js? %>
<link href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet' type='text/css'>
<%= javascript_include_tag "application" %>
<% end %>
<% end %>
<% if view_class == "users users-signout_confirm" %>
<%= javascript_include_tag "application" %>
<% end %>
<%= yield(:page_meta) %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= favicon_link_tag ApplicationConfig["FAVICON_URL"] %>
<link rel="apple-touch-icon" href="<%= asset_path "apple-icon.png" %>">
<link rel="apple-touch-icon" sizes="152x152" href="<%= asset_path "apple-icon-152x152.png" %>">
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path "apple-icon-180x180.png" %>">
<link rel="apple-touch-icon" sizes="167x167" href="<%= asset_path "apple-icon-167x167.png" %>">
<link href="<%= asset_path "android-icon-192x192.png" %>" rel="icon" sizes="192x192" />
<link href="<%= asset_path "android-icon-128x128.png" %>" rel="icon" sizes="128x128" />
<meta name="apple-mobile-web-app-title" content="dev.to">
<meta name="application-name" content="dev.to">
<meta property="fb:pages" content="568966383279687" />
<meta name="theme-color" content="#000000" />
<% if view_class.include? "users-signout_confirm" %>
<%= csrf_meta_tags %>
<% end %>
<link rel="manifest" href="/manifest.json" />
<link rel="search" href="https://dev.to/search.xml" type="application/opensearchdescription+xml" title="<%= community_qualified_name %>" />
<% end %>
</head>
<body data-user-status="<%= user_logged_in_status %>" data-pusher-key="<%= ApplicationConfig["PUSHER_KEY"] %>">
<div id="body-styles"></div>
<% if user_signed_in? && !internal_navigation? %>
<%= render "layouts/user_config" %>
<% end %>
<% unless internal_navigation? %>
<div id="audiocontent">
<%= yield(:audio) %>
</div>
<% end %>
<% cache("application-top-bar--#{user_signed_in?}--#{internal_navigation?}", expires_in: 100.hours) do %>
<%= render "layouts/top_bar" unless internal_navigation? %>
<% end %>
<div id="message-notice"></div>
<div id="page-content" class="universal-page-content-wrapper <%= view_class %>" data-current-page="<%= current_page %>">
<% if flash[:global_notice] %>
<div class="global-notice">
<%= flash[:global_notice] %>
</div>
<% end %>
<div id="page-content-inner">
<%= yield %>
</div>
<%= render "shell/top" %>
<style>.app-shell-loader {display: none;}</style>
<div id="page-content" class="universal-page-content-wrapper <%= view_class %>" data-current-page="<%= current_page %>">
<% if flash[:global_notice] %>
<div class="global-notice">
<%= flash[:global_notice] %>
</div>
<% unless internal_navigation? %>
<% cache("footer-and-signup-modal--#{user_signed_in?}--#{ApplicationConfig['HEROKU_SLUG_COMMIT']}", expires_in: 24.hours) do %>
<%= render "layouts/footer" %>
<%= render "layouts/signup_modal" unless user_signed_in? %>
<% end %>
<div id="live-article-indicator" class="live-article-indicator"></div>
<%= image_tag("twitter-logo.svg", class: "icon-img", style: "display:none", alt: "twitter logo") %>
<%= image_tag("github-logo.svg", class: "icon-img", style: "display:none", alt: "github logo") %>
<% end %>
</body>
</html>
<% end %>
<div id="page-content-inner">
<%= yield %>
</div>
</div>
<%= render "shell/bottom" %>

View file

@ -1,144 +1,136 @@
var CACHE_VERSION = 'v2.7.1';
var CACHE_NAME = CACHE_VERSION + ':sw-cache::';
var REQUESTS_LIMIT = 40;
// Serviceworkers file. This code gets installed in users browsers and runs code before the request is made.
<% unless Rails.env.test? %>
const staticCacheName = 'static-1';
const expectedCaches = [
staticCacheName
];
function onInstall(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function prefill(cache) {
return cache.addAll([
'<%= asset_path "base.js" %>',
'<%= asset_path "minimal.css" %>',
'/offline.html',
'<%= asset_path "devword.png" %>',
'<%= asset_path "wires.png" %>',
'<%= asset_path "comments-bubble.png" %>',
'<%= asset_path "reactions-stack.png" %>',
'<%= asset_path "emoji/emoji-one-heart.png" %>',
'<%= asset_path "emoji/emoji-one-unicorn.png" %>',
'<%= asset_path "emoji/emoji-one-bookmark.png" %>',
'<%= asset_path "emoji/apple-fire.png" %>',
]).then(function () {
console.log("WORKER: Install completed");
});
})
);
}
self.addEventListener('install', event => {
self.skipWaiting();
function onActivate(event) {
console.log('[Serviceworker]', "Activating!", event);
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.filter(function(cacheName) {
// Return true if you want to remove this cache,
return cacheName.indexOf(CACHE_NAME) !== 0;
}).map(function(cacheName) {
return caches.delete(cacheName);
// Populate initial serviceworker cache.
event.waitUntil(
caches.open(staticCacheName)
.then(cache => cache.addAll([
"/shell_top", // head, top bar, inline styles
"/shell_bottom", // footer
"/async_info/shell_version", // For comparing changes in the shell. Should be incremented with style changes.
"/404.html", // Not found page
"/500.html", // Error page
"/offline.html" //Offline page
]))
);
});
// remove caches that aren't in expectedCaches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.map(key => {
if (!expectedCaches.includes(key)) return caches.delete(key);
})
);
})
);
}
))
);
});
function onFetch(event) {
if ( requestBlackListed(event.request) ) { return; }
event.respondWith(
caches.match(event.request).then(function (cached) {
if (cached && shouldReturnStraightFromCache(event.request.url)) {
return cached;
}
return fetch(event.request)
.then(fetchedFromNetwork)
.catch(function fallback(err) {
console.log(err)
return cached || caches.match('/offline.html');
})
}
)
);
function requestBlackListed(request){
var url = new URL(request.url);
var test = (event.request.method !== 'GET' ||
event.request.url.match(/\/notifications(\?|\/)/) ||
url.host.indexOf("facebook") > -1 ||
url.host.indexOf("google") > -1 )
return test
}
function shouldReturnStraightFromCache(url) {
return (url === "<%= asset_path "base.js" %>" ||
url === "<%= asset_path "minimal.css" %>" ||
url.indexOf(".self-") > -1 ||
(url.indexOf("search?") > -1 && url.indexOf("&i=i") > -1) ||
url.indexOf("freetls.fastly.net") > -1)
}
function fetchedFromNetwork(response) {
var cacheCopy = response.clone();
caches
.open(CACHE_NAME + 'pages')
.then(function add(cache) {
if ( shouldReturnStraightFromCache(event.request.url) ) {
cache.put(event.request, cacheCopy);
// Create a composed streamed webpage with shell and core content
function createPageStream(request) {
const stream = new ReadableStream({
start(controller) {
if (!caches.match('/shell_top') || !caches.match('/shell_top')) { //return if shell isn't cached.
return
}
garbageCollect(cache);
})
return response;
}
function garbageCollect(cache) {
cache.keys().then(function count(keys) {
if (keys.length > REQUESTS_LIMIT) {
cache.delete(keys[0]);
cache.delete(keys[1]);
cache.delete(keys[2]);
cache.delete(keys[3]);
cache.delete(keys[4]);
cache.delete(keys[5]);
cache.delete(keys[6]);
cache.delete(keys[7]);
cache.delete(keys[8]);
cache.delete(keys[9]);
cache.delete(keys[10]);
// the body url is the request url plus 'include'
const url = new URL(request.url);
url.searchParams.set('i', 'i'); // Adds ?i=i or &i=i, which is our indicator for "internal" partial page
const startFetch = caches.match('/shell_top');
const endFetch = caches.match('/shell_bottom');
const middleFetch = fetch(url).then(response => {
if (!response.ok && response.status === 404) {
return caches.match('/404.html');
}
if (!response.ok && response.status != 404) {
return caches.match('/500.html');
}
return response;
}).catch(err => caches.match('/offline.html'));
function pushStream(stream) {
const reader = stream.getReader();
return reader.read().then(function process(result) {
if (result.done) return;
controller.enqueue(result.value);
return reader.read().then(process);
});
}
startFetch
.then(response => pushStream(response.body))
.then(() => middleFetch)
.then(response => pushStream(response.body))
.then(() => endFetch)
.then(response => pushStream(response.body))
.then(() => controller.close());
}
})
});
return new Response(stream, {
headers: {'Content-Type': 'text/html; charset=utf-8'}
});
}
function unableToResolve () {
console.log('WORKER: fetch request failed in both cache and network.');
return caches.match('/offline.html')
}
};
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.origin === location.origin) {
//
if (event.clientId === "" && // Not fetched via AJAX after page load.
event.request.method == "GET" && // Don't fetch on POST, DELETE, etc.
!event.request.referrer.includes('/signout_confirm') && // If this is the referrer, we instead want to flush.
!url.href.includes('i=i') && // Parameter representing "internal" navigation.
!url.href.includes('?signin') && // Don't run on sign in.
!url.href.includes('.') && // Don't run on sign in.
!url.href.includes('/onboarding') && // Don't run on onboarding.
!url.href.includes('/api/') && // Don't run on API endpoints.
!url.href.includes('/users/auth') && // Don't run on authentication.
!url.href.includes('/shell_') && // Don't fetch for shell.
!url.href.includes('/admin') && // Don't fetch for administrate dashboard.
!url.href.includes('/internal') && // Don't fetch for internal dashboard.
caches.match('/shell_top') && // Ensure shell_top is in the cache.
caches.match('/shell_bottom')) { // Ensure shell_bottom is in the cache.
event.respondWith(createPageStream(event.request)); // Respond with the stream
function onPush(event) {
var title = 'DEV Connect 👋 ';
var body = event.data.text();
var icon = '<%= image_path "devlogo-pwa-128.png" %>';
var tag = 'default-tag' + body;
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag,
data: {
url: 'https://dev.to/connect'
// Ping version endpoint to see if we should fetch new shell.
if (!caches.match('/async_info/shell_version')) { // Check if we have a cached shell version
caches.open(staticCacheName)
.then(cache => cache.addAll([
"/async_info/shell_version",
]))
return
}
fetch('/async_info/shell_version').then(response => response.json()).then(json => {
caches.match('/async_info/shell_version').then(cachedResponse => cachedResponse.json()).then(cacheJson => {
if (cacheJson['version'] != json['version']) {
caches.open(staticCacheName)
.then(cache => cache.addAll([
"/shell_top",
"/shell_bottom",
"/async_info/shell_version"
]))
}
})
});
return;
}
})
);
};
function onNotificationClick(event) {
if (Notification.prototype.hasOwnProperty('data')) {
var url = event.notification.data.url;
event.waitUntil(clients.openWindow(url));
}
}
self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);
self.addEventListener('push', onPush);
self.addEventListener('notificationclick', onNotificationClick)
// Fetch new shell upon events that signify change in session.
if (event.clientId === "" &&
(event.request.referrer.includes('/signout_confirm') || url.href.includes('?signin') || url.href.includes('/onboarding'))) {
caches.open(staticCacheName)
.then(cache => cache.addAll([
"/shell_top",
"/shell_bottom",
]))
}
}
});
<% end %>

View file

@ -0,0 +1,19 @@
<!-- Start Bottom Shell -->
<% unless internal_navigation? %>
<% cache("footer-and-signup-modal--#{user_signed_in?}--#{ApplicationConfig['HEROKU_SLUG_COMMIT']}", expires_in: 24.hours) do %>
<%= render "layouts/footer" %>
<%= render "layouts/signup_modal" unless user_signed_in? %>
<% end %>
<div id="live-article-indicator" class="live-article-indicator"></div>
<%= image_tag("twitter-logo.svg", class: "icon-img", style: "display:none", alt: "twitter logo") %>
<%= image_tag("github-logo.svg", class: "icon-img", style: "display:none", alt: "github logo") %>
<% if @shell %>
<script defer>
if (!document.title) {
document.title = document.getElementsByTagName("TITLE")[1].innerHTML
}
</script>
<% end %>
</body>
</html>
<% end %>

View file

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<% title = yield(:title) %>
<title><%= title || "#{ApplicationConfig['COMMUNITY_NAME']} Community" %></title>
<% unless internal_navigation? %>
<meta name="last-updated" content="<%= Time.current %>">
<meta name="user-signed-in" content="<%= user_signed_in? %>">
<meta name="algolia-public-id" content="<%= ApplicationConfig["ALGOLIASEARCH_APPLICATION_ID"] %>">
<meta name="algolia-public-key" content="<%= ALGOLIASEARCH_PUBLIC_SEARCH_ONLY_KEY %>">
<meta name="environment" content="<%= Rails.env %>">
<%= render "layouts/styles" %>
<style>
.home {
overflow: hidden;
position: relative;
text-align: center;
min-height: 440px;
margin: auto;
max-width: 1250px;
}
@media screen and (min-width: 950px) {
.home {
margin-top: 26px;
}
}
</style>
<%= javascript_pack_tag "manifest", defer: true %>
<%= javascript_pack_tag "vendor", defer: true %>
<%= javascript_pack_tag "Search", defer: true %>
<%= javascript_include_tag "base", defer: true %>
<% if user_signed_in? %>
<%= javascript_pack_tag "onboardingRedirectCheck", defer: true %>
<%= javascript_pack_tag "contentDisplayPolicy", defer: true %>
<% end %>
<%= yield(:page_meta) %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= favicon_link_tag ApplicationConfig["FAVICON_URL"] %>
<link rel="apple-touch-icon" href="<%= asset_path "apple-icon.png" %>">
<link rel="apple-touch-icon" sizes="152x152" href="<%= asset_path "apple-icon-152x152.png" %>">
<link rel="apple-touch-icon" sizes="180x180" href="<%= asset_path "apple-icon-180x180.png" %>">
<link rel="apple-touch-icon" sizes="167x167" href="<%= asset_path "apple-icon-167x167.png" %>">
<link href="<%= asset_path "android-icon-192x192.png" %>" rel="icon" sizes="192x192" />
<link href="<%= asset_path "android-icon-128x128.png" %>" rel="icon" sizes="128x128" />
<meta name="apple-mobile-web-app-title" content="dev.to">
<meta name="application-name" content="dev.to">
<meta property="fb:pages" content="568966383279687" />
<meta name="theme-color" content="#000000" />
<% if view_class.include? "users-signout_confirm" %>
<%= csrf_meta_tags %>
<% end %>
<link rel="manifest" href="/manifest.json" />
<link rel="search" href="https://dev.to/search.xml" type="application/opensearchdescription+xml" title="<%= community_qualified_name %>" />
<% end %>
</head>
<% unless internal_navigation? %>
<body data-user-status="<%= user_logged_in_status %>" data-pusher-key="<%= ApplicationConfig["PUSHER_KEY"] %>">
<div id="body-styles"></div>
<% if user_signed_in? %>
<%= render "layouts/user_config" %>
<% end %>
<div id="audiocontent">
<%= yield(:audio) %>
</div>
<% cache("application-top-bar--#{user_signed_in?}", expires_in: 100.hours) do %>
<%= render "layouts/top_bar" %>
<% end %>
<div id="message-notice"></div>
<div class="app-shell-loader">
loading...
</div>
<!-- End Top Shell -->
<% end %>

View file

@ -1,9 +1,9 @@
<% title "Confirm Signout - DEV" %>
<div class="signout_confirm-wrapper">
<h1>Are you sure you want to sign out?</h1>
<h2><%= link_to "Yes. Sign out.", destroy_user_session_path, method: :delete, data: { "no-instant" => "" } %></h2>
<%= form_tag destroy_user_session_path, method: :delete do %>
<button class="cta">Yes. Sign out.</button>
<% end %>
</div>

View file

@ -102,8 +102,8 @@ Rails.application.configure do
config.logger = ActiveSupport::TaggedLogging.new(logger)
config.after_initialize do
Bullet.enable = true
Bullet.console = true
Bullet.enable = false
Bullet.console = false
Bullet.rails_logger = true
end
end

View file

@ -28,7 +28,7 @@ Rails.application.configure do
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
config.public_file_server.headers = {
"Cache-Control" => "public, s-maxage=2592000, max-age=86400"
"Cache-Control" => "public, s-maxage=#{30.days.to_i}, max-age=#{30.days.to_i}"
}
# Compress JavaScripts and CSS.

View file

@ -1,6 +1,6 @@
FastlyRails.configure do |c|
c.api_key = ApplicationConfig["FASTLY_API_KEY"] # Fastly api key, required
c.max_age = 30.days.to_i # time in seconds, optional, defaults to 2592000 (30 days)
c.max_age = 1.day.to_i # time in seconds, optional, defaults to 2592000 (30 days)
c.service_id = ApplicationConfig["FASTLY_SERVICE_ID"] # Fastly service you will be using, required
c.stale_if_error = 26_400
c.purging_enabled = Rails.env.production? # No need to configure a client locally (since 0.4.0)

View file

@ -0,0 +1,4 @@
# Trigger cache purges for globally-cached endpoints that could have changed
CacheBuster.bust("/shell_top")
CacheBuster.bust("/shell_bottom")
CacheBuster.bust("/async_info/shell_version")

View file

@ -235,7 +235,8 @@ Rails.application.routes.draw do
get "/social_previews/comment/:id" => "social_previews#comment", :as => :comment_social_preview
get "/async_info/base_data", controller: "async_info#base_data", defaults: { format: :json }
get "/async_info/shell_version", controller: "async_info#shell_version", defaults: { format: :json }
get "/future", to: redirect("devteam/the-future-of-dev-160n")
# Settings
@ -338,6 +339,10 @@ Rails.application.routes.draw do
get "/serviceworker" => "service_worker#index"
get "/manifest" => "service_worker#manifest"
get "/shell_top" => "shell#top"
get "/shell_bottom" => "shell#bottom"
get "/new" => "articles#new"
get "/new/:template" => "articles#new"

View file

@ -35,6 +35,20 @@ rule, you should avoid relying on JavaScript for layout when working on DEV.
We use [PreactJS][preact], a lightweight alternative to ReactJS, and we try to
reduce our bundle size with [dynamic imports][dynamic_imports].
## Service workers and shell architecture
We make use of serviceworkers to cache portions of the page.
We cache styles here as well as script fingerprinting, so we should increment
the number in `/async_info/shell_version` any time we change core CSS or
JavaScript.
Serviceworkers can be controlled in the `application` tab of Chrome.
Serviceworkers are a reverse proxy that runs in the browser in a non-blocking
thread, supported by most major browsers. You may want to disable or bypass
Serviceworkers in development while making changes to avoid having everything
cached.
## Worst technical debt
The most widespread elements of technical debt in this application reside on the
@ -63,16 +77,16 @@ shared among all users.
## Inter-page navigation
DEV uses a variation of "instant click," which swaps out page content instead
of making full-page requests. This approach is similar to the one used by the
Rails gem `Turbolinks`, but our approach is more lightweight. The library is
modified to work specifically with this Rails app and does not swap out reused
elements like the navigation bar or the footer. The code for this functionality
is viewable in `app/assets/javascripb/base.js.erb`.
DEV uses a variation of "instant click," which swaps out page content instead of
making full-page requests. This approach is similar to the one used by the Rails
gem `Turbolinks`, but our approach is more lightweight. The library is modified
to work specifically with this Rails app and does not swap out reused elements
like the navigation bar or the footer. The code for this functionality is
viewable in `app/assets/javascripb/base.js.erb`.
There are a few caveats regarding this approach. Using our approach means a
non-trivial amount of functionality is reloaded on page change. A similar
amount of reloading occurs when using `window.InstantClick.on('change', someFunction)`.
non-trivial amount of functionality is reloaded on page change. A similar amount
of reloading occurs when using `window.InstantClick.on('change', someFunction)`.
This results in code that looks something like this:
```javascript
@ -92,9 +106,9 @@ that front is welcome!
## Articles (or posts)
Articles are the primary form of user generated content in the application. An
Article has many comments and taggings through the acts-as-taggable gem,
belongs to a single user (and possibly an organization), and is the core unit
of content.
Article has many comments and taggings through the acts-as-taggable gem, belongs
to a single user (and possibly an organization), and is the core unit of
content.
## Comments
@ -149,5 +163,6 @@ This is far from a complete view of the app, but it covers a few core concepts.
[fastly]: https://www.fastly.com/
[rails_caching]: https://guides.rubyonrails.org/caching_with_rails.html
[preact]: https://preactjs.com/
[dynamic_imports]: https://dev.to/goenning/how-we-reduced-our-initial-jscss-size-by-67-3ac0
[dynamic_imports]:
https://dev.to/goenning/how-we-reduced-our-initial-jscss-size-by-67-3ac0
[fastly_rails]: https://github.com/fastly/fastly-rails

View file

@ -4,6 +4,10 @@
<title>The page you were looking for doesn't exist (404)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
.not-found-page {
height: 100vh;
min-height: 500px;
}
.tv__outer {
display: table;
position: absolute;
@ -41,15 +45,18 @@
margin-right: auto;
display: table;
font-size: 22px;
font-weight: 200;
font-weight: 500;
}
img {
max-width: 85%;
width: 300px;
border: 25px solid rgb(54, 55, 124);
border-radius: 36px;
}
p {
text-align: center;
max-width: 90%;
}
@-webkit-keyframes fade {
@ -81,13 +88,15 @@
</head>
<body>
<div class="tv__outer">
<div class="tv__middle">
<img class="tv__inner--special" src="https://i.imgur.com/Vyyjycv.gif" alt="404 not found image as TV static">
<img class="tv__inner" src="https://i.imgur.com/3v0LCiP.jpg" alt="404 not found DEV logo">
<br>
<p class="tv__inner--text">You have lost your way. <br> <a href="https://dev.to/">Return to Home Page</a></p>
</div>
<div class="not-found-page base-background-color">
<div class="tv__outer">
<div class="tv__middle">
<img class="tv__inner--special" src="https://i.imgur.com/Vyyjycv.gif" alt="404 not found image as TV static">
<img class="tv__inner" src="https://i.imgur.com/3v0LCiP.jpg" alt="404 not found DEV logo">
<br>
<p class="tv__inner--text">This page does not exist <br><br> <a href="https://dev.to/">Return to Home Page</a></p>
</div>
</div>
</div>
</body>
</html>

View file

@ -4,22 +4,26 @@
<title>We're sorry, but something went wrong (500)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body {
body {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
font-weight: 300;
}
.unhandled-error-page {
height: 100vh;
min-height: 550px;
padding-top: 10vh;
}
.dialog{
background:rgb(236, 236, 236);
border:1px solid rgb(217, 217, 217);
border:2px solid rgb(217, 217, 217);
text-align:center;
padding:20px 0px;
padding:30px 0px;
margin:5% auto;
width:80%;
max-width:600px;
max-width:580px;
min-width:315px;
color:rgb(44, 46, 50);
border-radius:5px;
min-height: 600px;
border-radius:8px;
min-height: 500px;
}
textarea{
width:75%;
@ -51,6 +55,7 @@
img{
max-width:100%;
height:200px;
border-radius: 5px;
}
</style>
<script src='https://www.google.com/recaptcha/api.js'></script>
@ -58,13 +63,15 @@
<body>
<!-- This file lives in public/500.html -->
<div class="dialog">
<div>
<img alt="500 error" src="https://media.giphy.com/media/SX9TFmjF0eChq/200.gif" height="200" />
<h1>500 Error</h1>
<h2>It's not you, it's me.</h2>
<div class="unhandled-error-page base-background-color">
<div class="dialog">
<div>
<img alt="500 error" src="https://media.giphy.com/media/SX9TFmjF0eChq/200.gif" height="200" />
<h1>Ooooops...</h1>
<h2>You have encountered an error.</h2>
</div>
<p style="margin: 10%">Our team has been notified. Please feel welcome to also submit a public <a href="https://github.com/thepracticaldev/dev.to/issues/new?template=bug_report.md">bug report</a>. <br><br/>We apologize for any inconvenience.</p>
</div>
<p style="margin: 10%">Sorry you're hitting this page. <br> Please help us by submitting a <a href="https://github.com/thepracticaldev/dev.to/issues/new?template=bug_report.md">bug report</a>. <br>Thank you!</p>
</div>
</body>
</html>

View file

@ -92,7 +92,7 @@
.rainbow-logo {
border-radius: 10%;
max-width: 50%;
max-width: 40%;
max-height: 400px;
}

View file

@ -30,6 +30,13 @@ RSpec.describe "AsyncInfo", type: :request do
end
end
describe "GET /async_info/shell_version" do
it "returns shell_version" do
get "/async_info/shell_version"
expect(response.body).to include("version")
end
end
describe "#remember_user_token" do
# We require the remember_user_token key bc we also use it for caching in Fastly
# If this key changes, Fastly needs to be updated

View file

@ -4,7 +4,7 @@ RSpec.describe "ServiceWorker", type: :request do
describe "GET /serviceworker.js" do
it "renders file with proper text" do
get "/serviceworker.js"
expect(response.body).to include("var CACHE_VERSION")
expect(response.body).to include("Serviceworkers file")
end
it "renders javascript file" do

View file

@ -15,7 +15,7 @@ RSpec.describe "Deleting Comment", type: :system, js: true do
it "works" do
visit "/"
visit comment.path + "/delete_confirm"
click_link("DELETE")
click_button("DELETE")
expect(page).to have_current_path(article.path)
end
end