From 5cdba7fc5157902f5fc7f9eccef2eb809356abb7 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Sat, 24 Aug 2019 15:49:27 -0300 Subject: [PATCH 1/8] adds fetchFromServerPage --- react/utils/routes.ts | 61 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/react/utils/routes.ts b/react/utils/routes.ts index e949024b5..411fe24d3 100644 --- a/react/utils/routes.ts +++ b/react/utils/routes.ts @@ -1,8 +1,10 @@ +import { stringify } from 'query-string' +import { isEmpty } from 'ramda' + import navigationPageQuery from '../queries/navigationPage.graphql' import routePreviews from '../queries/routePreviews.graphql' -import { parseMessages } from './messages' import { generateExtensions } from './blocks' -import { isEmpty } from 'ramda' +import { parseMessages } from './messages' const parsePageQueryResponse = ( page: PageQueryResponse @@ -88,6 +90,61 @@ const parseDefaultPagesQueryResponse = ( } } +const runtimeFields = [ + 'appsEtag', + 'blocks', + 'blocksTree', + 'components', + 'contentMap', + 'extensions', + 'messages', + 'page', + 'pages', + 'query', + 'route', + 'runtimeMeta', + 'settings', +].join(',') + +export const fetchServerPage = async ({ + path, + query: rawQuery, +}: { + path: string + query?: Record +}): Promise => { + const query = stringify({ + ...rawQuery, + __pickRuntime: runtimeFields, + }) + const url = `${path}?${query}` + const page: ServerPageResponse = await fetch(url, { + headers: { + accept: 'application/json', + }, + }).then(response => response.json()) + const { + blocksTree, + blocks, + contentMap, + extensions: pageExtensions, + pages, + route, + route: { routeId }, + } = page + + const extensions = + !isEmpty(blocksTree) && blocksTree && blocks && contentMap + ? generateExtensions(blocksTree, blocks, contentMap, pages[routeId]) + : pageExtensions + + return { + ...page, + extensions, + matchingPage: route, + } +} + export const fetchNavigationPage = ({ apolloClient, routeId, From c376682484e5d9ec61ab573ee05684be8ab32766 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Sat, 24 Aug 2019 15:49:46 -0300 Subject: [PATCH 2/8] add feature flag activiation --- react/utils/flags.ts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 react/utils/flags.ts diff --git a/react/utils/flags.ts b/react/utils/flags.ts new file mode 100644 index 000000000..4d0a2fc66 --- /dev/null +++ b/react/utils/flags.ts @@ -0,0 +1,7 @@ +const flags = { + RENDER_NAVIGATION: true, +} + +window.flags = flags + +export const isEnabled = (flag: keyof typeof flags) => window.flags[flag] From fdf8150f54e93c5eb611d7db9ee005ad1b10e7e7 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Sat, 24 Aug 2019 15:50:03 -0300 Subject: [PATCH 3/8] navigate using render when flag is activated --- react/components/RenderProvider.tsx | 168 ++++++++++++++++++---------- react/typings/global.d.ts | 32 ++++++ react/utils/pages.ts | 21 +++- 3 files changed, 159 insertions(+), 62 deletions(-) diff --git a/react/components/RenderProvider.tsx b/react/components/RenderProvider.tsx index 25958cfc1..a05c8fd39 100644 --- a/react/components/RenderProvider.tsx +++ b/react/components/RenderProvider.tsx @@ -6,15 +6,17 @@ import debounce from 'debounce' import { canUseDOM } from 'exenv' import { History, UnregisterCallback } from 'history' import PropTypes from 'prop-types' -import { merge, mergeWith } from 'ramda' +import { merge, mergeDeepRight, mergeWith } from 'ramda' import React, { Component, Fragment, ReactElement } from 'react' import { ApolloProvider } from 'react-apollo' import { Helmet } from 'react-helmet' import { IntlProvider } from 'react-intl' import { fetchAssets, getImplementation, prefetchAssets } from '../utils/assets' +import { generateExtensions } from '../utils/blocks' import PageCacheControl from '../utils/cacheControl' import { getClient } from '../utils/client' +import { OperationContext } from '../utils/client/links/uriSwitchLink' import { traverseComponent } from '../utils/components' import { isSiteEditorIframe, @@ -22,6 +24,7 @@ import { ROUTE_CLASS_PREFIX, routeClass, } from '../utils/dom' +import { isEnabled } from '../utils/flags' import { goBack as pageGoBack, mapToQueryString, @@ -30,15 +33,17 @@ import { queryStringToMap, scrollTo as pageScrollTo, } from '../utils/pages' -import { fetchDefaultPages, fetchNavigationPage } from '../utils/routes' +import { + fetchDefaultPages, + fetchNavigationPage, + fetchServerPage, +} from '../utils/routes' import { TreePathContextProvider } from '../utils/treePath' import BuildStatus from './BuildStatus' import ExtensionManager from './ExtensionManager' import ExtensionPoint from './ExtensionPoint' import { RenderContextProvider } from './RenderContext' import RenderPage from './RenderPage' -import { generateExtensions } from '../utils/blocks' -import { OperationContext } from '../utils/client/links/uriSwitchLink' interface Props { children: ReactElement | null @@ -516,13 +521,18 @@ class RenderProvider extends Component { } const { navigationRoute, fetchPage } = state - const { id: page, params } = navigationRoute + const { id: maybePage, params } = navigationRoute const transientRoute = { ...route, ...navigationRoute } + + // We always have to navigate to a page. If none was found, we + // navigate to the current page with preview + const page = maybePage || route.id + const { [page]: { allowConditions, declarer }, } = pagesState const shouldSkipFetchNavigationData = - (!allowConditions && loadedPages.has(page)) || !fetchPage + (!allowConditions && loadedPages.has(maybePage)) || !fetchPage const query = queryStringToMap(location.search) as RenderRuntime['query'] if (shouldSkipFetchNavigationData) { @@ -561,7 +571,9 @@ class RenderProvider extends Component { page, preview: true, query, - route: transientRoute, + route: isEnabled('RENDER_NAVIGATION') + ? mergeDeepRight(route, navigationRoute) + : transientRoute, }, () => { this.replaceRouteClass(page) @@ -577,47 +589,79 @@ class RenderProvider extends Component { // well as the fields that need to be retrieved, but the logic // that the new state (extensions and assets) will be derived from // the results of this query will probably remain the same. - return fetchNavigationPage({ - apolloClient, - declarer, - locale, - paramsJSON, - production, - query: JSON.stringify(query), - renderMajor, - routeId, - skipCache: false, - }).then( - ({ - appsEtag, - cacheHints, - components, - extensions, - matchingPage, - messages, - pages, - settings, - }: ParsedPageQueryResponse) => { - const updatedRoute = { ...transientRoute, ...matchingPage } - this.setState( - { + return isEnabled('RENDER_NAVIGATION') + ? fetchServerPage({ + path: navigationRoute.path, + query, + }).then( + ({ appsEtag, - cacheHints: mergeWith(merge, this.state.cacheHints, cacheHints), - components: { ...this.state.components, ...components }, - extensions: { ...this.state.extensions, ...extensions }, - loadedPages: loadedPages.add(page), - messages: { ...this.state.messages, ...messages }, - page, + components, + extensions, + matchingPage, + messages, pages, - preview: false, - query, - route: updatedRoute, settings, - }, - () => this.sendInfoFromIframe() + }: ParsedServerPageResponse) => { + this.setState( + { + appsEtag, + components: { ...this.state.components, ...components }, + extensions: { ...this.state.extensions, ...extensions }, + loadedPages: loadedPages.add(matchingPage.routeId), + messages: { ...this.state.messages, ...messages }, + page: matchingPage.routeId, + pages, + preview: false, + query, + route: matchingPage, + settings, + }, + () => this.sendInfoFromIframe() + ) + } + ) + : fetchNavigationPage({ + apolloClient, + declarer, + locale, + paramsJSON, + production, + query: JSON.stringify(query), + renderMajor, + routeId, + skipCache: false, + }).then( + ({ + appsEtag, + cacheHints, + components, + extensions, + matchingPage, + messages, + pages, + settings, + }: ParsedPageQueryResponse) => { + const updatedRoute = { ...transientRoute, ...matchingPage } + this.setState( + { + appsEtag, + cacheHints: mergeWith(merge, this.state.cacheHints, cacheHints), + components: { ...this.state.components, ...components }, + extensions: { ...this.state.extensions, ...extensions }, + loadedPages: loadedPages.add(page), + messages: { ...this.state.messages, ...messages }, + page, + pages, + preview: false, + query, + route: updatedRoute, + settings, + }, + () => this.sendInfoFromIframe() + ) + } ) - } - ) } public prefetchPage = (pageName: string) => { @@ -723,6 +767,7 @@ class RenderProvider extends Component { production, culture: { locale }, route, + query, } = this.state const declarer = pagesState[page] && pagesState[page].declarer const { pathname } = window.location @@ -736,25 +781,32 @@ class RenderProvider extends Component { messages, pages, settings, - } = await fetchNavigationPage({ - apolloClient: this.apolloClient, - declarer, - locale, - paramsJSON, - path: pathname, - production, - query: '', - renderMajor, - routeId: page, - skipCache: true, - ...options, - }) + } = isEnabled('RENDER_NAVIGATION') + ? await fetchServerPage({ + path: route.path, + query, + }) + : await fetchNavigationPage({ + apolloClient: this.apolloClient, + declarer, + locale, + paramsJSON, + path: pathname, + production, + query: '', + renderMajor, + routeId: page, + skipCache: true, + ...options, + }) await new Promise(resolve => { this.setState( state => ({ appsEtag, - cacheHints, + cacheHints: isEnabled('RENDER_NAVIGATION') + ? state.cacheHints + : cacheHints, components, extensions: { ...state.extensions, diff --git a/react/typings/global.d.ts b/react/typings/global.d.ts index 20c7c945c..b6a752da1 100644 --- a/react/typings/global.d.ts +++ b/react/typings/global.d.ts @@ -305,6 +305,33 @@ declare global { routeId: string } + interface MatchingServerPage { + blockId: string + canonicalPath?: string + metaTags?: RouteMetaTags + pageContext: PageDataContext + title?: string + routeId: string + params: Record + id: string + path: string + domain: string + } + + interface ServerPageResponse { + appsEtag: RenderRuntime['appsEtag'] + blocks: RenderRuntime['blocks'] + blocksTree: RenderRuntime['blocksTree'] + cacheHints: RenderRuntime['cacheHints'] + contentMap: RenderRuntime['contentMap'] + components: RenderRuntime['components'] + extensions: RenderRuntime['extensions'] + messages: RenderRuntime['messages'] + pages: RenderRuntime['pages'] + route: MatchingServerPage + settings: RenderRuntime['settings'] + } + interface PageQueryResponse { blocksJSON: string blocksTreeJSON: string @@ -330,6 +357,10 @@ declare global { message: string } + interface ParsedServerPageResponse extends ServerPageResponse { + matchingPage: MatchingServerPage + } + interface ParsedPageQueryResponse { blocks: RenderRuntime['blocks'] blocksTree: RenderRuntime['blocksTree'] @@ -483,6 +514,7 @@ declare global { myvtexSSE: any rendered: Promise | RenderedFailure requestIdleCallback: (callback: (...args) => any | void) => number + flags: Record } interface BlockEntry { diff --git a/react/utils/pages.ts b/react/utils/pages.ts index 87be2a0d6..a0d1bd48c 100644 --- a/react/utils/pages.ts +++ b/react/utils/pages.ts @@ -4,6 +4,8 @@ import queryString from 'query-string' import { difference, is, isEmpty, keys, startsWith } from 'ramda' import RouteParser from 'route-parser' +import { isEnabled } from './flags' + const EMPTY_OBJECT = (Object.freeze && Object.freeze({})) || {} const removeTrailingParenthesis = (path: string) => @@ -247,9 +249,18 @@ export function navigate( const realHash = is(String, hash) ? `#${hash}` : '' const query = inputQuery || realQuery - const navigationRoute = page - ? getRouteFromPageName(page, pages, params) - : getRouteFromPath(to, pages, query, realHash) + let navigationRoute: any = {} + + if (isEnabled('RENDER_NAVIGATION')) { + const fallbackPage = { path: to, params: {}, id: '' } + const routeFromPage = page && getRouteFromPageName(page, pages, params) + const routeFromPath = getRouteFromPath(to, pages, query, realHash) + navigationRoute = routeFromPage || routeFromPath || fallbackPage + } else { + navigationRoute = page + ? getRouteFromPageName(page, pages, params) + : getRouteFromPath(to, pages, query, realHash) + } if (!navigationRoute) { console.warn( @@ -407,7 +418,9 @@ function routeIdFromPathAndQuery( const mappedSegments = query.map ? query.map.split(',') : [] let routeMatch: RouteMatch | null = null - if (mappedSegments.length > 0) { + // Don't use map segments to match a route when Render + // navigation is enabled + if (mappedSegments.length > 0 && !isEnabled('RENDER_NAVIGATION')) { routeMatch = routeMatchForMappedURL(mappedSegments, routes) } From 43718c77d3abcebfb2609431e222fc6078d34f5b Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Sat, 24 Aug 2019 15:50:17 -0300 Subject: [PATCH 4/8] remove unecessary postreleasy --- manifest.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/manifest.json b/manifest.json index 97b956d09..53ac24232 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,9 @@ { "vendor": "vtex", "name": "render-runtime", - "version": "8.50.0", + "version": "8.51.0-beta.2", "title": "VTEX Render runtime", "description": "The VTEX Render framework runtime", - "defaultLocale": "pt-BR", "builders": { "react": "3.x" }, @@ -12,14 +11,9 @@ "vtex.pages-graphql": "2.x" }, "mustUpdateAt": "2018-09-05", - "categories": [], - "registries": [ - "smartcheckout" - ], "settingsSchema": {}, "scripts": { - "prereleasy": "bash lint.sh", - "postreleasy": "vtex publish --verbose" + "prereleasy": "bash lint.sh" }, "$schema": "https://raw.githubusercontent.com/vtex/node-vtex-api/master/gen/manifest.schema" } From a590bb0b72bfeab19e6cbec5348647667cc1d6e1 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 26 Aug 2019 16:25:48 -0300 Subject: [PATCH 5/8] add fetch with retry and set credentials to same-origin --- react/utils/fetch.ts | 67 +++++++++++++++++++++++++++++++++++++++++++ react/utils/routes.ts | 6 ++-- 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 react/utils/fetch.ts diff --git a/react/utils/fetch.ts b/react/utils/fetch.ts new file mode 100644 index 000000000..c7066a846 --- /dev/null +++ b/react/utils/fetch.ts @@ -0,0 +1,67 @@ +const delay = (ms: number): Promise => { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +const RETRY_STATUSES = [ + 0, + 408, + 425, + 429, + 500, + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 510, + 511, +] + +const canRetry = (status: number) => RETRY_STATUSES.includes(status) + +const ok = (status: number) => 200 <= status && status < 300 + +export const fetchWithRetry = ( + url: string, + init: RequestInit, + maxRetries: number = 3 +) => { + let status = 500 + const callFetch = ( + attempt: number = 0 + ): Promise<{ response: Response; error: any }> => + fetch(url, init) + .then(response => { + status = response.status + return ok(status) + ? { response, error: null } + : response + .json() + .then(error => ({ response, error })) + .catch(() => ({ + response, + error: { message: 'Unable to parse JSON' }, + })) + }) + .then(({ response, error }) => { + if (error) { + throw new Error(error.message || 'Unknown error') + } + return { response, error: null } + }) + .catch(error => { + console.error(error) + + if (attempt >= maxRetries || !canRetry(status)) { + throw error + } + + const ms = 2 ** attempt * 500 + return delay(ms).then(() => callFetch(++attempt)) + }) + + return callFetch() +} diff --git a/react/utils/routes.ts b/react/utils/routes.ts index 411fe24d3..8fc14a60c 100644 --- a/react/utils/routes.ts +++ b/react/utils/routes.ts @@ -4,6 +4,7 @@ import { isEmpty } from 'ramda' import navigationPageQuery from '../queries/navigationPage.graphql' import routePreviews from '../queries/routePreviews.graphql' import { generateExtensions } from './blocks' +import { fetchWithRetry } from './fetch' import { parseMessages } from './messages' const parsePageQueryResponse = ( @@ -118,11 +119,12 @@ export const fetchServerPage = async ({ __pickRuntime: runtimeFields, }) const url = `${path}?${query}` - const page: ServerPageResponse = await fetch(url, { + const page: ServerPageResponse = await fetchWithRetry(url, { + credentials: 'same-origin', headers: { accept: 'application/json', }, - }).then(response => response.json()) + }).then(({ response }) => response.json()) const { blocksTree, blocks, From 4cbf88ea7c66bd33628ba5f5fd724ef32ed383bb Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 26 Aug 2019 16:26:19 -0300 Subject: [PATCH 6/8] remove special `map` code from navigation --- react/utils/pages.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/react/utils/pages.ts b/react/utils/pages.ts index a0d1bd48c..4512f3075 100644 --- a/react/utils/pages.ts +++ b/react/utils/pages.ts @@ -167,13 +167,34 @@ function getCanonicalPath( } export function getRouteFromPath( + path: string, + pages: Pages +): NavigationRoute | null { + const routeMatch = routeMatchFromPath(path, pages) + if (!routeMatch) { + return null + } + + const params = getPageParams(path, routeMatch.path) + const navigationPath = routeMatch.canonical + ? getCanonicalPath(routeMatch.canonical, params) || path + : path + + return { + id: routeMatch.id, + params, + path: navigationPath, + } +} + +export function getRouteFromPathOld( path: string, pages: Pages, query?: string, hash?: string ): NavigationRoute | null { const queryMap = query ? queryStringToMap(hash ? query + hash : query) : {} - const routeMatch = routeIdFromPathAndQuery(path, queryMap, pages) + const routeMatch = routeIdFromPathAndQueryOld(path, queryMap, pages) if (!routeMatch) { return null } @@ -254,12 +275,12 @@ export function navigate( if (isEnabled('RENDER_NAVIGATION')) { const fallbackPage = { path: to, params: {}, id: '' } const routeFromPage = page && getRouteFromPageName(page, pages, params) - const routeFromPath = getRouteFromPath(to, pages, query, realHash) + const routeFromPath = getRouteFromPath(to, pages) navigationRoute = routeFromPage || routeFromPath || fallbackPage } else { navigationRoute = page ? getRouteFromPageName(page, pages, params) - : getRouteFromPath(to, pages, query, realHash) + : getRouteFromPathOld(to, pages, query, realHash) } if (!navigationRoute) { @@ -410,7 +431,7 @@ function routeMatchFromPath(path: string, routes: Pages): RouteMatch | null { } } -function routeIdFromPathAndQuery( +function routeIdFromPathAndQueryOld( path: string, query: Record, routes: Pages From f648dffe8f4e991f8e71b0aea8d19b811c4fc7c4 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 26 Aug 2019 16:36:31 -0300 Subject: [PATCH 7/8] test in 10% of the users --- react/utils/flags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/utils/flags.ts b/react/utils/flags.ts index 4d0a2fc66..eebf8b152 100644 --- a/react/utils/flags.ts +++ b/react/utils/flags.ts @@ -1,5 +1,5 @@ const flags = { - RENDER_NAVIGATION: true, + RENDER_NAVIGATION: Math.random() < 0.1, } window.flags = flags From 37d8fa672283748693280c24037aac85e6bbb469 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Mon, 26 Aug 2019 16:40:42 -0300 Subject: [PATCH 8/8] uses same page meta for navigation --- react/components/RenderProvider.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/react/components/RenderProvider.tsx b/react/components/RenderProvider.tsx index a05c8fd39..f98a32264 100644 --- a/react/components/RenderProvider.tsx +++ b/react/components/RenderProvider.tsx @@ -526,14 +526,13 @@ class RenderProvider extends Component { // We always have to navigate to a page. If none was found, we // navigate to the current page with preview - const page = maybePage || route.id - - const { - [page]: { allowConditions, declarer }, - } = pagesState + const allowConditions = + pagesState[maybePage] && pagesState[maybePage].allowConditions + const declarer = pagesState[maybePage] && pagesState[maybePage].declarer const shouldSkipFetchNavigationData = (!allowConditions && loadedPages.has(maybePage)) || !fetchPage const query = queryStringToMap(location.search) as RenderRuntime['query'] + const page = maybePage || route.id if (shouldSkipFetchNavigationData) { return this.setState(