diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index adb49a400f8..07a21d893db 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -3,7 +3,7 @@ # and run "github-actions-wac build" (or "ghawac build") to regenerate this file. # For more information, run "github-actions-wac --help". name: Pull Requests -'on': pull_request +"on": pull_request concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -19,7 +19,7 @@ jobs: - uses: webiny/action-conventional-commits@v1.3.0 runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false validateCommitsDev: name: Validate commit messages (dev branch, 'feat' commits not allowed) @@ -34,7 +34,7 @@ jobs: allowed-commit-types: fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false constants: name: Create constants @@ -87,12 +87,14 @@ jobs: $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false assignMilestone: name: Assign milestone needs: constants - if: needs.constants.outputs.is-fork-pr != 'true' + if: >- + needs.constants.outputs.is-fork-pr != 'true' && + github.event.pull_request.milestone == null steps: - uses: actions/setup-node@v4 with: @@ -115,7 +117,7 @@ jobs: milestone: ${{ steps.get-milestone-to-assign.outputs.milestone }} runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false build: name: Build @@ -147,7 +149,7 @@ jobs: path: ${{ github.base_ref }}/.webiny/cached-packages key: ${{ needs.constants.outputs.run-cache-key }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysis: needs: @@ -185,7 +187,7 @@ jobs: working-directory: ${{ github.base_ref }} runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysisTs: name: Static code analysis (TypeScript) @@ -211,7 +213,7 @@ jobs: run: yarn cy:ts working-directory: ${{ github.base_ref }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageConstants: needs: @@ -239,7 +241,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageRun: needs: @@ -260,7 +262,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsNoStorageConstants.outputs.packages-to-jest-test != '[]' @@ -357,7 +359,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddbRun: needs: @@ -377,7 +379,7 @@ jobs: fromJson(needs.jestTestsddbConstants.outputs.packages-to-jest-test) }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsddbConstants.outputs.packages-to-jest-test != '[]' @@ -474,7 +476,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-esRun: needs: @@ -495,7 +497,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} @@ -604,7 +606,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-osRun: needs: @@ -625,7 +627,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }} diff --git a/.github/workflows/wac/pullRequests.wac.ts b/.github/workflows/wac/pullRequests.wac.ts index 723dcc6e574..60b4a28f061 100644 --- a/.github/workflows/wac/pullRequests.wac.ts +++ b/.github/workflows/wac/pullRequests.wac.ts @@ -210,7 +210,10 @@ export const pullRequests = createWorkflow({ assignMilestone: createJob({ name: "Assign milestone", needs: "constants", - if: "needs.constants.outputs.is-fork-pr != 'true'", + if: [ + "needs.constants.outputs.is-fork-pr != 'true'", + "github.event.pull_request.milestone == null" + ].join(" && "), steps: [ { name: "Print latest Webiny version", diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts new file mode 100644 index 00000000000..967a6704370 --- /dev/null +++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts @@ -0,0 +1,45 @@ +import extractPeLoaderDataFromHtml from "../../src/render/extractPeLoaderDataFromHtml"; + +describe("extractPeLoaderDataFromHtml Tests", () => { + it("must detect pe-loader-data-cache tags in given HTML", async () => { + const results = extractPeLoaderDataFromHtml(TEST_STRING); + + expect(results).toEqual([ + { + key: "GfT8AoRsYT-1238102521", + value: [ + { + description: + "The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.", + id: "5e9d0d95eda69955f709d1eb", + name: "Falcon 1", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_1" + }, + { + description: + "Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.", + id: "5e9d0d95eda69973a809d1ec", + name: "Falcon 9", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_9" + }, + { + description: + "With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.", + id: "5e9d0d95eda69974db09d1ed", + name: "Falcon Heavy", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_Heavy" + }, + { + description: + "Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.", + id: "5e9d0d96eda699382d09d1ee", + name: "Starship", + wikipedia: "https://en.wikipedia.org/wiki/SpaceX_Starship" + } + ] + } + ]); + }); +}); + +const TEST_STRING = `...
  • Starship

    Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.

    More info at https://en.wikipedia.org/wiki/SpaceX_Starship
  • `; diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json index 4f5b541a939..6de200a1885 100644 --- a/packages/api-prerendering-service/package.json +++ b/packages/api-prerendering-service/package.json @@ -24,6 +24,7 @@ "@webiny/handler-client": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", + "he": "^1.2.0", "lodash": "^4.17.21", "object-hash": "^3.0.0", "pluralize": "^8.0.0", @@ -40,6 +41,7 @@ "@babel/plugin-proposal-export-default-from": "^7.23.3", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", + "@types/he": "^1.2.3", "@types/object-hash": "^2.2.1", "@types/puppeteer-core": "^5.4.0", "@webiny/cli": "0.0.0", diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts new file mode 100644 index 00000000000..1618e550a27 --- /dev/null +++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts @@ -0,0 +1,69 @@ +import { PeLoaderCacheEntry } from "./types"; +import he from "he"; + +const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => { + const regex = + /<\/pe-loader-data-cache>/gm; + let m; + + while ((m = regex.exec(content)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + const [, key, value] = m; + + // JSON in `data-value` is HTML Entities-encoded. So, we need to decode it here first. + const heParsedValue = he.decode(value); + const parsedValue = JSON.parse(heParsedValue); + return { key, value: parsedValue }; + } + + return null; +}; + +export default (content: string): PeLoaderCacheEntry[] => { + if (!content) { + return []; + } + + const cachedData: PeLoaderCacheEntry[] = []; + const regex = /<\/pe-loader-data-cache>/gm; + let m; + + while ((m = regex.exec(content)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + const [matchedTag] = m; + + if (!matchedTag) { + continue; + } + + const parsedTag = parsePeLoaderDataCacheTag(matchedTag); + if (!parsedTag) { + continue; + } + + cachedData.push(parsedTag); + } + + if (cachedData.length > 0) { + const uniqueMap: Record = cachedData.reduce( + (collection, peLoaderDataCache) => { + collection[`${peLoaderDataCache.key || ""}${peLoaderDataCache.value || ""}`] = + peLoaderDataCache; + + return collection; + }, + {} as Record + ); + + return Object.values(uniqueMap); + } + return cachedData; +}; diff --git a/packages/api-prerendering-service/src/render/renderUrl.ts b/packages/api-prerendering-service/src/render/renderUrl.ts index 57aaf7b1fcd..c3333b71c5f 100644 --- a/packages/api-prerendering-service/src/render/renderUrl.ts +++ b/packages/api-prerendering-service/src/render/renderUrl.ts @@ -14,8 +14,11 @@ import injectRenderTs from "./injectRenderTs"; import injectTenantLocale from "./injectTenantLocale"; import injectNotFoundPageFlag from "./injectNotFoundPageFlag"; import getPsTags from "./getPsTags"; +import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; import shortid from "shortid"; import { + GraphQLCacheEntry, + PeLoaderCacheEntry, RenderResult, RenderUrlCallableParams, RenderUrlParams, @@ -108,8 +111,8 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta } }, { - name: "graphql.json", - body: JSON.stringify(render.meta.gqlCache), + name: "cache.json", + body: JSON.stringify(render.meta.cachedData), type: "application/json", meta: {} } @@ -118,12 +121,6 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta ]; }; -interface GraphQLCache { - query: any; - variables: Record; - data: Record; -} - export const defaultRenderUrlFunction = async ( url: string, params: RenderUrlCallableParams @@ -168,7 +165,13 @@ export const defaultRenderUrlFunction = async ( } }); - const gqlCache: GraphQLCache[] = []; + const cachedData: { + apolloGraphQl: GraphQLCacheEntry[]; + peLoaders: PeLoaderCacheEntry[]; + } = { + apolloGraphQl: [], + peLoaders: [] + }; // TODO: should be a plugin. browserPage.on("response", async response => { @@ -189,7 +192,7 @@ export const defaultRenderUrlFunction = async ( if (mustCache) { const data = Array.isArray(responses) ? responses[i].data : responses.data; - gqlCache.push({ + cachedData.apolloGraphQl.push({ query, variables, data @@ -208,11 +211,15 @@ export const defaultRenderUrlFunction = async ( return window.getApolloState(); }); + const content = await browserPage.content(); + + cachedData.peLoaders = extractPeLoaderDataFromHtml(content); + return { - content: await browserPage.content(), + content, // TODO: ideally, meta should be assigned here in a more "plugins style" way, not hardcoded. meta: { - gqlCache, + cachedData, apolloState } }; diff --git a/packages/api-prerendering-service/src/render/types.ts b/packages/api-prerendering-service/src/render/types.ts index b629aa8cee5..d92a917d6f0 100644 --- a/packages/api-prerendering-service/src/render/types.ts +++ b/packages/api-prerendering-service/src/render/types.ts @@ -23,11 +23,25 @@ export interface RenderApolloState { /** * @internal */ + +export interface GraphQLCacheEntry { + query: any; + variables: Record; + data: Record; +} + +export interface PeLoaderCacheEntry { + key: string; + value: string; +} + export interface RenderResult { content: string; meta: { apolloState: RenderApolloState; - gqlCache: { + cachedData: { + apolloGraphQl: GraphQLCacheEntry[]; + peLoaders: PeLoaderCacheEntry[]; [key: string]: any; }; [key: string]: any; diff --git a/packages/app-page-builder-elements/src/contexts/PageElements.tsx b/packages/app-page-builder-elements/src/contexts/PageElements.tsx index e05dd3fc659..42b994ff4db 100644 --- a/packages/app-page-builder-elements/src/contexts/PageElements.tsx +++ b/packages/app-page-builder-elements/src/contexts/PageElements.tsx @@ -31,7 +31,8 @@ export const PageElementsProvider = ({ renderers = {}, modifiers, beforeRenderer = null, - afterRenderer = null + afterRenderer = null, + loaderCache }: PageElementsProviderProps) => { // Attributes-related callbacks. const getElementAttributes = useCallback( @@ -42,7 +43,8 @@ export const PageElementsProvider = ({ renderers, modifiers, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme] @@ -79,7 +81,8 @@ export const PageElementsProvider = ({ modifiers, assignStyles: customAssignStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme, customElementStylesCallback, customAssignStylesCallback] @@ -95,7 +98,8 @@ export const PageElementsProvider = ({ modifiers, assignStyles: customAssignStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme, customStylesCallback, customAssignStylesCallback] @@ -122,7 +126,8 @@ export const PageElementsProvider = ({ setElementStylesCallback, setStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }; return ( diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts new file mode 100644 index 00000000000..095b13e4bba --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useState, type DependencyList } from "react"; +import { createObjectHash } from "./useLoader/createObjectHash"; +import { useRenderer } from ".."; + +export interface RendererLoader { + data: TData | null; + loading: boolean; + cacheHit: boolean; + cacheKey: null | string; +} + +export interface UseLoaderOptions { + cacheKey?: DependencyList; +} + +export function useLoader( + loaderFn: () => Promise, + options?: UseLoaderOptions +): RendererLoader { + const { getElement, loaderCache } = useRenderer(); + + const element = getElement(); + + const elementDataCacheKey = element.id; + const optionsCacheKey = options?.cacheKey || []; + const cacheKey = createObjectHash([elementDataCacheKey, ...optionsCacheKey]); + + const cachedData = useMemo(() => { + return loaderCache.read(cacheKey); + }, [cacheKey]); + + const [loader, setLoader] = useState>( + cachedData + ? { + data: cachedData, + loading: false, + cacheHit: true, + cacheKey + } + : { data: null, loading: true, cacheHit: false, cacheKey: null } + ); + + useEffect(() => { + if (cacheKey === loader.cacheKey) { + return; + } + + if (cachedData) { + setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true }); + return; + } + + setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false }); + loaderFn().then(data => { + loaderCache.write(cacheKey, data); + setLoader({ data, loading: false, cacheKey, cacheHit: false }); + }); + }, [cacheKey]); + + return loader; +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts new file mode 100644 index 00000000000..31abcbdc536 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts @@ -0,0 +1,6 @@ +export interface ILoaderCache { + read: (key: string) => TData | null; + write: (key: string, value: TData) => void; + remove: (key: string) => void; + clear: () => void; +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts new file mode 100644 index 00000000000..5649d75f195 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts @@ -0,0 +1,19 @@ +import { ILoaderCache } from "./ILoaderCache"; + +export class NullLoaderCache implements ILoaderCache { + read() { + return null; + } + + write() { + return; + } + + remove() { + return; + } + + clear() { + return; + } +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts new file mode 100644 index 00000000000..f619c11e202 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts @@ -0,0 +1,17 @@ +export const createObjectHash = (object: Record) => { + const jsonString = JSON.stringify(object); + + // Create a hash string from string. + if (jsonString.length === 0) { + return ""; + } + + let hash = 0; + for (let i = 0; i < jsonString.length; i++) { + const charCode = jsonString.charCodeAt(i); + hash = (hash << 5) - hash + charCode; + hash |= 0; // Convert to 32bit integer + } + + return String(hash); +}; diff --git a/packages/app-page-builder-elements/src/index.ts b/packages/app-page-builder-elements/src/index.ts index 4865e5ee8f2..19ca8705fd2 100644 --- a/packages/app-page-builder-elements/src/index.ts +++ b/packages/app-page-builder-elements/src/index.ts @@ -6,6 +6,7 @@ export * from "./hooks/usePage"; export * from "./hooks/usePageElements"; export * from "./hooks/useRenderer"; export * from "./hooks/useFacepaint"; +export * from "./hooks/useLoader"; export * from "./contexts/PageElements"; export * from "./contexts/Page"; diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index a96ec8f6a17..19ea0dac7e3 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -6,6 +6,7 @@ import React, { HTMLAttributes } from "react"; import { type CSSObject } from "@emotion/react"; import { StylesObject, ThemeBreakpoints, Theme } from "@webiny/theme/types"; import { ElementInputs, ElementInputValues } from "~/inputs/ElementInput"; +import { ILoaderCache } from "~/hooks/useLoader/ILoaderCache"; export interface Page { id: string; @@ -34,6 +35,7 @@ export interface PageElementsProviderProps { beforeRenderer?: React.ComponentType | null; afterRenderer?: React.ComponentType | null; children?: React.ReactNode; + loaderCache: ILoaderCache; } export type AttributesObject = React.ComponentProps; diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 259b3561bb7..b3bae68ed7d 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from "react"; +import React, { Fragment, useMemo } from "react"; import { HasPermission } from "@webiny/app-security"; import { Plugins, AddMenu as Menu, createProviderPlugin } from "@webiny/app-admin"; import { Global, css } from "@emotion/react"; @@ -16,6 +16,7 @@ import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandle import { InjectElementVariables } from "~/render/variables/InjectElementVariables"; import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph"; import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; export type { EditorProps }; export { EditorRenderer }; @@ -24,8 +25,12 @@ export * from "~/admin/views/Pages/hooks"; const PageBuilderProviderPlugin = createProviderPlugin(Component => { return function PageBuilderProvider({ children }) { + const noLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( - + {children} diff --git a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx index dc331449aec..d3481e39903 100644 --- a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx +++ b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx @@ -4,6 +4,7 @@ import { usePageBuilder } from "~/hooks/usePageBuilder"; import { mediaToContainer } from "./mediaToContainer"; import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider"; import styled from "@emotion/styled"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; const ResponsiveContainer = styled.div` container-type: inline-size; @@ -39,9 +40,15 @@ export const ResponsiveElementsProvider = ({ children }: { children: React.React } as Theme; }, [pageBuilder.theme]); + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( - {children} + + {children} + ); }; diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx index 5caadd3d916..0eda5c49f37 100644 --- a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx +++ b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx @@ -4,6 +4,7 @@ import { DisplayMode, PbTheme } from "~/types"; import { Theme } from "@webiny/app-theme/types"; import { useTheme } from "@webiny/app-theme"; import { PageElementsProvider } from "./PageElementsProvider"; +import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; export interface ResponsiveDisplayMode { displayMode: DisplayMode; @@ -35,12 +36,13 @@ export interface PageBuilderContext { } export interface PageBuilderProviderProps { + loaderCache: ILoaderCache; children?: React.ReactChild | React.ReactChild[]; } export const PageBuilderContext = React.createContext(undefined); -export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => { +export const PageBuilderProvider = ({ children, loaderCache }: PageBuilderProviderProps) => { const [displayMode, setDisplayMode] = React.useState(DisplayMode.DESKTOP); const [revisionType, setRevisionType] = React.useState( PbRevisionType.published @@ -62,7 +64,7 @@ export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => { } }} > - {children} + {children} ); }; diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx index 7c6e2cce40b..9c390c040ea 100644 --- a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx +++ b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx @@ -27,13 +27,19 @@ import { Theme } from "@webiny/app-theme/types"; import { plugins } from "@webiny/plugins"; import { PbRenderElementPlugin } from "~/types"; +import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; interface PageElementsProviderProps { theme?: Theme; + loaderCache: ILoaderCache; children: React.ReactNode; } -export const PageElementsProvider = ({ theme, children }: PageElementsProviderProps) => { +export const PageElementsProvider = ({ + theme, + loaderCache, + children +}: PageElementsProviderProps) => { const pageBuilder = usePageBuilder(); const getRenderers = useCallback(() => { @@ -76,6 +82,7 @@ export const PageElementsProvider = ({ theme, children }: PageElementsProviderPr theme={theme ?? (pageBuilder.theme as Theme)} renderers={getRenderers} modifiers={modifiers} + loaderCache={loaderCache} > {children} diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx index c1a36616691..8531ad23b56 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx @@ -32,6 +32,7 @@ import { plugins } from "@webiny/plugins"; import { PbEditorPageElementPlugin } from "~/types"; import { ElementControls } from "./EditorPageElementsProvider/ElementControls"; import { mediaToContainer } from "./EditorPageElementsProvider/mediaToContainer"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; interface EditorPageElementsProviderProps { children: React.ReactNode; @@ -96,11 +97,16 @@ export const EditorPageElementsProvider = ({ children }: EditorPageElementsProvi } as Theme; }, [pageBuilder.theme]); + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( {children} diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx index 41d07a3bf1e..8a7035ce9dd 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { css } from "emotion"; import { plugins } from "@webiny/plugins"; import ElementPreview from "./SaveDialog/ElementPreview"; import { CircularProgress } from "@webiny/ui/Progress"; import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; import { Dialog, @@ -91,6 +92,10 @@ const SaveDialog = (props: Props) => { } }; + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return (
    @@ -142,7 +147,7 @@ const SaveDialog = (props: Props) => { - + diff --git a/packages/app-website/src/LinkPreload.tsx b/packages/app-website/src/LinkPreload.tsx index 82a6fae34ab..594e56dc795 100644 --- a/packages/app-website/src/LinkPreload.tsx +++ b/packages/app-website/src/LinkPreload.tsx @@ -5,6 +5,7 @@ import { makeDecoratable } from "@webiny/app"; import { Link, To } from "@webiny/react-router"; import { getPrerenderId, isPrerendering } from "@webiny/app/utils"; import { GET_PUBLISHED_PAGE } from "./Page/graphql"; +import { usePageElements } from "@webiny/app-page-builder-elements"; const preloadedPaths: string[] = []; @@ -23,6 +24,8 @@ const defaultGetPreloadPagePath: GetPreloadPagePath = path => { const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => { const getPreloadPagePath = options.getPreloadPagePath ?? defaultGetPreloadPagePath; + const { loaderCache } = usePageElements(); + const apolloClient = useApolloClient(); const preloadPath = async (pathname: string) => { // We only need a clean pathname, without query parameters. @@ -34,22 +37,32 @@ const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => { preloadedPaths.push(pathname); - const graphqlJson = `graphql.json?k=${getPrerenderId()}`; + const graphqlJson = `cache.json?k=${getPrerenderId()}`; const fetchPath = pathname !== "/" ? `${pathname}/${graphqlJson}` : `/${graphqlJson}`; const pageState = await fetch(fetchPath.replace("//", "/")) .then(res => res.json()) .catch(() => null); if (pageState) { - for (let i = 0; i < pageState.length; i++) { - const { query, variables, data } = pageState[i]; - apolloClient.writeQuery({ - query: gql` - ${query} - `, - data, - variables - }); + const { apolloGraphQl, peLoaders } = pageState; + if (Array.isArray(apolloGraphQl)) { + for (let i = 0; i < apolloGraphQl.length; i++) { + const { query, variables, data } = apolloGraphQl[i]; + apolloClient.writeQuery({ + query: gql` + ${query} + `, + data, + variables + }); + } + } + + if (Array.isArray(peLoaders)) { + for (let i = 0; i < peLoaders.length; i++) { + const { key, value } = peLoaders[i]; + loaderCache.write(key, value); + } } } else { const finalPath = getPreloadPagePath(pathname); diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx index cd6dabbad88..5b917e0845c 100644 --- a/packages/app-website/src/Website.tsx +++ b/packages/app-website/src/Website.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { App, AppProps, Decorator, GenericComponent } from "@webiny/app"; import { ApolloProvider } from "@apollo/react-hooks"; import { CacheProvider } from "@emotion/react"; @@ -9,6 +9,7 @@ import { PageBuilderProvider } from "@webiny/app-page-builder/contexts/PageBuild import { PageBuilder } from "@webiny/app-page-builder/render"; import { RouteProps } from "@webiny/react-router"; import { LinkPreload } from "~/LinkPreload"; +import { WebsiteLoaderCache } from "~/utils/WebsiteLoaderCache"; export interface WebsiteProps extends AppProps { apolloClient?: ReturnType; @@ -17,9 +18,13 @@ export interface WebsiteProps extends AppProps { const PageBuilderProviderHOC: Decorator< GenericComponent<{ children: React.ReactNode }> > = PreviousProvider => { + const websiteLoaderCache = useMemo(() => { + return new WebsiteLoaderCache(); + }, []); + return function PageBuilderProviderHOC({ children }) { return ( - + {children} ); diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts new file mode 100644 index 00000000000..56d31a03264 --- /dev/null +++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts @@ -0,0 +1,37 @@ +import type { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; +import { getPrerenderId, isPrerendering } from "@webiny/app/utils"; +import { PeLoaderHtmlCache } from "~/utils/WebsiteLoaderCache/PeLoaderHtmlCache"; + +export class WebsiteLoaderCache implements ILoaderCache { + private loaderCache: Record = {}; + + read(key: string) { + if (key in this.loaderCache) { + return this.loaderCache[key]; + } + + if (getPrerenderId()) { + this.loaderCache[key] = PeLoaderHtmlCache.read(key); + return this.loaderCache[key]; + } + + this.loaderCache[key] = null; + return this.loaderCache[key]; + } + + write(key: string, value: TData) { + this.loaderCache[key] = value; + + if (isPrerendering()) { + PeLoaderHtmlCache.write(key, value); + } + } + + remove(key: string) { + delete this.loaderCache[key]; + } + + clear() { + this.loaderCache = {}; + } +} diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts new file mode 100644 index 00000000000..10b3b83a1bb --- /dev/null +++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts @@ -0,0 +1,26 @@ +export class PeLoaderHtmlCache { + static read(key: string) { + const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`); + if (!htmlElement) { + return null; + } + + const cachedResultElementValue = htmlElement.getAttribute("data-value"); + if (!cachedResultElementValue) { + return null; + } + + try { + return JSON.parse(cachedResultElementValue) as TData; + } catch { + return null; + } + } + + static write(key: string, value: TData) { + const html = ``; + document.body.insertAdjacentHTML("beforeend", html); + } +} diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js new file mode 100644 index 00000000000..db5be4d75bc --- /dev/null +++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js @@ -0,0 +1,12 @@ +const MATCH_STRING = "ConditionalCheckFailedException: The conditional request failed"; + +module.exports = ({ error }) => { + const { message } = error; + + if (typeof message === "string" && message.includes(MATCH_STRING)) { + return { + message: `Looks like the deployment failed because Pulumi tried to insert a record into a DynamoDB table, but the record already exists. The easiest way to resolve this is to delete the record from the table and try again.`, + learnMore: "https://webiny.link/deployment-ddb-conditional-check-failed" + }; + } +}; diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js index 9d8c0c559b6..ddf536f4487 100644 --- a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js +++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js @@ -1,4 +1,5 @@ +const ddbPutItemConditionalCheckFailed = require("./ddbPutItemConditionalCheckFailed"); const missingFilesInBuild = require("./missingFilesInBuild"); const pendingOperationsInfo = require("./pendingOperationsInfo"); -module.exports = [missingFilesInBuild, pendingOperationsInfo]; +module.exports = [ddbPutItemConditionalCheckFailed, missingFilesInBuild, pendingOperationsInfo]; diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts index 0d526db9ccb..fc5f949fb18 100644 --- a/packages/cli-plugin-extensions/src/promptQuestions.ts +++ b/packages/cli-plugin-extensions/src/promptQuestions.ts @@ -23,10 +23,7 @@ export const promptQuestions: QuestionCollection = [ choices: [ { name: "Admin extension", value: "admin" }, { name: "API extension", value: "api" }, - - // TODO: Bring back when we design the new PB Element React Configs API. - // { name: "Page Builder element", value: "pbElement" }, - + { name: "Page Builder element", value: "pbElement" }, { name: "Website extension", value: "website" } ] }, diff --git a/yarn.lock b/yarn.lock index e052fcca3e4..101bb1ad4f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12121,6 +12121,13 @@ __metadata: languageName: node linkType: hard +"@types/he@npm:^1.2.3": + version: 1.2.3 + resolution: "@types/he@npm:1.2.3" + checksum: e77851c73dd7b9902d92fe0118a26246a7f3676a3a1c6eb1408305187ef73b57c22550b1435946b983267f961d935554d5d0e1b458416932552f31e763e1aa41 + languageName: node + linkType: hard + "@types/hoist-non-react-statics@npm:^3.3.5": version: 3.3.5 resolution: "@types/hoist-non-react-statics@npm:3.3.5" @@ -14836,6 +14843,7 @@ __metadata: "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.24.0 "@sparticuz/chromium": 123.0.1 + "@types/he": ^1.2.3 "@types/object-hash": ^2.2.1 "@types/puppeteer-core": ^5.4.0 "@webiny/api": 0.0.0 @@ -14848,6 +14856,7 @@ __metadata: "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/utils": 0.0.0 + he: ^1.2.0 lodash: ^4.17.21 object-hash: ^3.0.0 pluralize: ^8.0.0