Skip to content

Commit

Permalink
Allow React front-end to generate static HTML pages (#1194)
Browse files Browse the repository at this point in the history
This adds functionality for the React front-end to generate its own "static pages", which consist of completely self-contained HTML that isn't progressively enhanced in any way.

The idea here is for us to be able to create HTML content in our server-side renderer that isn't intended for delivery to a browser, but rather for alternate media such as a PDF (via WeasyPrint) or richly-formatted email.

There's a few reasons we might want to do this:

* It's much easier to write valid HTML in React than it is in Django templates.
* It's easier for our developers to only learn one way of creating HTML content, rather than multiple ways.
* It's easier to unit test our content; currently all our Django templates aren't very well-tested in part because it's not easy to test them, whereas it _is_ straightforward to test React code.
* It's easier to ensure that our HTML-generating code won't raise exceptions, because it's TypeScript whereas Django templates have no type safety.
* We might want to reuse similar components in both alternate media and our website (for example, a function that "prettifies" a U.S. phone number).
* We sometimes duplicate view logic in Python and TypeScript, such as in #892, which this could help us avoid.
* Django templates tend to be tightly coupled to Django models, which makes them harder to reuse and test.  Rendering them React-side might mean a bit more work because it doesn't have direct access to our models, but it could also mean a better separation of concerns and more reusable code.
* We're eventually going to internationalize the site (see #12) and it's very likely that our internationalization solution on the React side will be based on ICU MessageFormat, which results in more user-friendly localization than Django's gettext-based solution.  Also, it's much easier for developers to learn only one internationalization solution.

As a proof-of-concept, this adds a page at `/dev/examples/static-page` that renders a simple static page consisting of the following HTML:

```
<!DOCTYPE html>
<html>
  <meta charSet="utf-8" />
  <title>This is an example static page.</title>
  <p>Hello, this is an example static page&hellip;</p>
</html>
```

Visiting this page in the browser will yield only that HTML and nothing else.
  • Loading branch information
toolness authored Apr 15, 2020
1 parent 404e463 commit 2da0eb2
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 47 deletions.
37 changes: 29 additions & 8 deletions frontend/lambda/lambda.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export interface LambdaResponse {
/** The HTML of the initial render of the page. */
html: string;

/**
* Whether or not the response represents an entire self-contained, static
* web page: one doesn't need to be put in a template and/or progressively
* enhanced in any way.
*/
isStaticContent: boolean;

/** The <title> tag for the initial render of the page. */
titleTag: string;

Expand Down Expand Up @@ -117,6 +124,18 @@ function renderAppHtml(
);
}

function renderStaticMarkup(
event: AppProps,
context: AppStaticContext,
jsx: JSX.Element
): string {
return ReactDOMServer.renderToStaticMarkup(
<ServerRouter event={event} context={context}>
<App {...event} children={jsx} />
</ServerRouter>
);
}

/**
* Generate the response for a given handler request, including the initial
* HTML for the requested URL.
Expand All @@ -141,23 +160,24 @@ function generateResponse(event: AppProps): LambdaResponse {
publicPath: event.server.webpackPublicPathURL,
});
const helmetContext: HelmetContext = {};
const html = renderAppHtml(event, context, extractor, helmetContext);
let html = renderAppHtml(event, context, extractor, helmetContext);
const helmet = assertNotUndefined(helmetContext.helmet);
let modalHtml = "";
if (context.modal) {
modalHtml = ReactDOMServer.renderToStaticMarkup(
<ServerRouter event={event} context={context}>
<App {...event} modal={context.modal} />
</ServerRouter>
);
let isStaticContent = false;
if (context.staticContent) {
html = renderStaticMarkup(event, context, context.staticContent);
isStaticContent = true;
}
const modalHtml = context.modal
? renderStaticMarkup(event, context, context.modal)
: "";
let location = null;
if (context.url) {
context.statusCode = 302;
location = context.url;
}
return {
html,
isStaticContent,
titleTag: helmet.title.toString(),
metaTags: helmet.meta.toString(),
scriptTags: extractor.getScriptTags(),
Expand Down Expand Up @@ -206,6 +226,7 @@ export function errorCatchingHandler(event: EventProps): LambdaResponse {
const helmet = assertNotUndefined(helmetContext.helmet);
return {
html,
isStaticContent: false,
titleTag: helmet.title.toString(),
metaTags: helmet.meta.toString(),
scriptTags: "",
Expand Down
6 changes: 6 additions & 0 deletions frontend/lib/app-static-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface AppStaticContext {
/** The modal to render server-side, if any. */
modal?: JSX.Element;

/**
* The static content to render server-side, if any. If provided, it
* is expected to be an entire HTML5-complaint web page.
*/
staticContent?: JSX.Element;

/**
* If the page contains a GraphQL query whose results should be
* pre-fetched, this will contain its value.
Expand Down
20 changes: 5 additions & 15 deletions frontend/lib/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,10 @@ export interface AppProps {
legacyFormSubmission?: AppLegacyFormSubmission;

/**
* If we're on the server-side and there's a modal on the page, we
* will actually be rendered *twice*: once with the modal background,
* and again with the modal itself. In the latter case, this prop will
* be populated with the content of the modal.
* If provided, this will *not* render a whole website, but instead just
* the single child wrapped in an AppContext.
*/
modal?: JSX.Element;

/**
* The site to render. This is intended primarily for testing purposes.
*/
siteComponent?: React.ComponentType<AppSiteProps>;
children?: JSX.Element;
}

export type AppPropsWithRouter = AppProps & RouteComponentProps<any>;
Expand Down Expand Up @@ -274,9 +267,6 @@ export class AppWithoutRouter extends React.Component<
}

getSiteComponent(): React.ComponentType<AppSiteProps> {
if (this.props.siteComponent) {
return this.props.siteComponent;
}
switch (this.props.server.siteType) {
case "JUSTFIX":
return LoadableJustfixSite;
Expand All @@ -286,11 +276,11 @@ export class AppWithoutRouter extends React.Component<
}

render() {
if (this.props.modal) {
if (this.props.children) {
return (
<AppContext.Provider
value={this.getAppContext()}
children={this.props.modal}
children={this.props.children}
/>
);
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/lib/dev/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ExampleFormWithoutRedirectPage,
} from "./example-form-page";
import { StyleGuide } from "./style-guide";
import { ExampleStaticPage } from "./example-static-page";

const LoadableExamplePage = loadable(
() => friendlyLoad(import("./example-loadable-page")),
Expand Down Expand Up @@ -167,6 +168,11 @@ export default function DevRoutes(): JSX.Element {
/>
<Route path={dev.examples.metaTag} exact component={ExampleMetaTagPage} />
<Route path={dev.examples.query} exact component={ExampleQueryPage} />
<Route
path={dev.examples.staticPage}
exact
component={ExampleStaticPage}
/>
</Switch>
);
}
13 changes: 13 additions & 0 deletions frontend/lib/dev/example-static-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from "react";
import { StaticPage } from "../static-page";

export const ExampleStaticPage: React.FC<{}> = () => (
<StaticPage>
<html>
{/* Yes, this is valid HTML5. */}
<meta charSet="utf-8" />
<title>This is an example static page.</title>
<p>Hello, this is an example static page&hellip;</p>
</html>
</StaticPage>
);
1 change: 1 addition & 0 deletions frontend/lib/dev/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function createDevRouteInfo(prefix: string) {
clientSideError: `${prefix}/examples/client-side-error`,
metaTag: `${prefix}/examples/meta-tag`,
query: `${prefix}/examples/query`,
staticPage: `${prefix}/examples/static-page`,
},
};
}
43 changes: 43 additions & 0 deletions frontend/lib/static-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect } from "react";
import { withRouter, RouteComponentProps } from "react-router-dom";
import { getAppStaticContext } from "./app-static-context";

export type StaticPageProps = { children: JSX.Element };

/**
* A <StaticPage> represents a web page of completely self-contained HTML
* that isn't progressively enhanced in any way. Almost all components that
* use this should pass an <html> element as a child.
*
* The primary use case for this component is for content that isn't
* intended for use in a browser, but rather for alternate media
* such as a PDF (via WeasyPrint) or richly-formatted HTML email.
*
* Using this element will actually *not* render anything above it in
* the component heirarchy. If it's visited via a <Link> or any other
* pushState-based mechanism, it will cause a hard refresh on the user's
* browser.
*/
export const StaticPage = withRouter(
(props: RouteComponentProps & StaticPageProps) => {
// If the user got here through a <Link> or other type of
// dynamic redirect, we want to trigger a page reload so
// that our static content is rendered server-side. However,
// we also want to ensure that if the user presses the
// back button on their browser, it triggers a hard redirect/reload
// instead of a popstate event (which won't do anything because
// we're a static page without any JS). So we'll add a
// querystring to trigger this.
//
// Note that the following querystring should never actually
// be included in a <Link> on the site, or we'll break the user's
// browsing history.
useEffect(() => window.location.replace("?staticView"));

const staticCtx = getAppStaticContext(props);
if (staticCtx) {
staticCtx.staticContent = props.children;
}
return null;
}
);
6 changes: 3 additions & 3 deletions frontend/lib/tests/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ import { defaultContext, AppContext } from "../app-context";
describe("App", () => {
let appContext = defaultContext;

const AppContextCapturer = React.forwardRef<HTMLDivElement>((props, ref) => {
const AppContextCapturer = () => {
appContext = useContext(AppContext);
return <p>HAI</p>;
});
};

const buildPal = (initialSession = FakeSessionInfo) => {
const props: AppProps = {
initialURL: "/",
locale: "",
initialSession,
server: FakeServerInfo,
siteComponent: AppContextCapturer,
children: <AppContextCapturer />,
};
const pal = new ReactTestingLibraryPal(
(
Expand Down
12 changes: 12 additions & 0 deletions project/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,18 @@ def test_pages_with_redirects_work(client):
assert response['location'] == react_url('/')


def test_static_pages_work(client):
response = client.get('/dev/examples/static-page')
assert response.status_code == 200
assert response.content.decode("utf-8") == (
'<!DOCTYPE html>'
'<html><meta charSet="utf-8"/>'
'<title>This is an example static page.</title>'
'<p>Hello, this is an example static page\u2026</p>'
'</html>'
)


def test_pages_with_extra_bundles_work(client):
response = client.get('/dev/examples/loadable-page')
assert response.status_code == 200
Expand Down
57 changes: 36 additions & 21 deletions project/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import time
import logging
from typing import NamedTuple, List, Dict, Any, Optional
from django.http import HttpResponseBadRequest
from django.http import HttpResponseBadRequest, HttpResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.utils.safestring import SafeString
Expand Down Expand Up @@ -77,6 +77,7 @@ class LambdaResponse(NamedTuple):
'''

html: SafeString
is_static_content: bool
title_tag: SafeString
meta_tags: SafeString
script_tags: SafeString
Expand Down Expand Up @@ -116,10 +117,10 @@ def add_graphql_fragments(query: str) -> str:
return '\n'.join(all_graphql)


def run_react_lambda(initial_props) -> LambdaResponse:
def run_react_lambda(initial_props, initial_render_time: int = 0) -> LambdaResponse:
start_time = time.time_ns()
response = lambda_service.run_handler(initial_props)
render_time = int((time.time_ns() - start_time) / NS_PER_MS)
render_time = initial_render_time + int((time.time_ns() - start_time) / NS_PER_MS)

pf = response['graphQLQueryToPrefetch']
if pf is not None:
Expand All @@ -130,6 +131,7 @@ def run_react_lambda(initial_props) -> LambdaResponse:

return LambdaResponse(
html=SafeString(response['html']),
is_static_content=response['isStaticContent'],
modal_html=SafeString(response['modalHtml']),
title_tag=SafeString(response['titleTag']),
meta_tags=SafeString(response['metaTags']),
Expand All @@ -142,6 +144,29 @@ def run_react_lambda(initial_props) -> LambdaResponse:
)


def run_react_lambda_with_prefetching(initial_props, request) -> LambdaResponse:
lambda_response = run_react_lambda(initial_props)

if lambda_response.status == 200 and lambda_response.graphql_query_to_prefetch:
# The page rendered, but it has a "loading..." message somewhere on it
# that's waiting for a GraphQL request to complete. Let's pre-fetch that
# request and re-render the page, so that the user receives it without
# any such messages (and so the user can see all the content if their
# JS isn't working).
pfquery = lambda_response.graphql_query_to_prefetch
initial_props['server']['prefetchedGraphQLQueryResponse'] = {
'graphQL': pfquery.graphql,
'input': pfquery.input,
'output': execute_query(request, pfquery.graphql, pfquery.input)
}
lambda_response = run_react_lambda(
initial_props,
initial_render_time=lambda_response.render_time
)

return lambda_response


def execute_query(request, query: str, variables=None) -> Dict[str, Any]:
result = schema.execute(query, context=request, variables=variables)
if result.errors:
Expand Down Expand Up @@ -270,23 +295,7 @@ def react_rendered_view(request):
'testInternalServerError': TEST_INTERNAL_SERVER_ERROR,
})

lambda_response = run_react_lambda(initial_props)
render_time = lambda_response.render_time

if lambda_response.status == 200 and lambda_response.graphql_query_to_prefetch:
# The page rendered, but it has a "loading..." message somewhere on it
# that's waiting for a GraphQL request to complete. Let's pre-fetch that
# request and re-render the page, so that the user receives it without
# any such messages (and so the user can see all the content if their
# JS isn't working).
pfquery = lambda_response.graphql_query_to_prefetch
initial_props['server']['prefetchedGraphQLQueryResponse'] = {
'graphQL': pfquery.graphql,
'input': pfquery.input,
'output': execute_query(request, pfquery.graphql, pfquery.input)
}
lambda_response = run_react_lambda(initial_props)
render_time += lambda_response.render_time
lambda_response = run_react_lambda_with_prefetching(initial_props, request)

script_tags = lambda_response.script_tags
if lambda_response.status == 500:
Expand All @@ -295,7 +304,13 @@ def react_rendered_view(request):
elif lambda_response.status == 302 and lambda_response.location:
return redirect(to=lambda_response.location)

logger.debug(f"Rendering {url} in Node.js took {render_time} ms.")
logger.debug(f"Rendering {url} in Node.js took {lambda_response.render_time} ms.")

if lambda_response.is_static_content:
return HttpResponse(
"<!DOCTYPE html>" + lambda_response.html,
status=lambda_response.status
)

return render(request, 'index.html', {
'initial_render': lambda_response.html,
Expand Down

0 comments on commit 2da0eb2

Please sign in to comment.