From 7e8f4e09f65099eae5643c5455fd6d553c7ee243 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 5 Dec 2024 06:32:17 +0100 Subject: [PATCH 01/28] fix: allow specifying dependency with exact version --- .../src/generateExtension.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/cli-plugin-extensions/src/generateExtension.ts b/packages/cli-plugin-extensions/src/generateExtension.ts index 67af6b1741..b405ac5c26 100644 --- a/packages/cli-plugin-extensions/src/generateExtension.ts +++ b/packages/cli-plugin-extensions/src/generateExtension.ts @@ -93,9 +93,26 @@ export const generateExtension = async ({ input, ora, context }: GenerateExtensi } try { - const { stdout } = await execa("npm", ["view", packageName, "version", "json"]); - - packageJsonUpdates[packageName] = `^${stdout}`; + const parsedPackageName = (() => { + const parts = packageName.split("@"); + if (packageName.startsWith("@")) { + return { name: parts[0] + parts[1], version: parts[2] }; + } + + return { name: parts[0], version: parts[1] }; + })(); + + if (parsedPackageName.version) { + packageJsonUpdates[parsedPackageName.name] = parsedPackageName.version; + } else { + const { stdout } = await execa("npm", [ + "view", + parsedPackageName.name, + "version" + ]); + + packageJsonUpdates[packageName] = `^${stdout}`; + } } catch (e) { throw new Error( `Could not find ${log.error.hl( From 64f83d5f321c5b1b5562a45a9da2d327c884fda1 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 3 Dec 2024 13:30:35 +0100 Subject: [PATCH 02/28] fix(plugins): improve types of plugins returned from the container --- packages/plugins/src/PluginsContainer.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/plugins/src/PluginsContainer.ts b/packages/plugins/src/PluginsContainer.ts index 48ecdd719a..7407178beb 100644 --- a/packages/plugins/src/PluginsContainer.ts +++ b/packages/plugins/src/PluginsContainer.ts @@ -1,6 +1,8 @@ import { Plugin, PluginCollection } from "./types"; import uniqid from "uniqid"; +export type WithName = T & { name: string }; + const isOptionsObject = (item?: any) => item && !Array.isArray(item) && !item.type && !item.name; const normalizeArgs = (args: any[]): [Plugin[], any] => { let options = {}; @@ -53,32 +55,32 @@ const assign = ( export class PluginsContainer { private plugins: Record = {}; - private _byTypeCache: Record = {}; + private _byTypeCache: Record[]> = {}; constructor(...args: PluginCollection) { this.register(...args); } - public byName(name: T["name"]): T | null { + public byName(name: T["name"]) { if (!name) { return null; } /** * We can safely cast name as string, we know it is so. */ - return this.plugins[name as string] as T; + return this.plugins[name as string] as WithName; } - public byType(type: T["type"]): T[] { + public byType(type: T["type"]) { if (this._byTypeCache[type]) { - return Array.from(this._byTypeCache[type]) as T[]; + return Array.from(this._byTypeCache[type]) as WithName[]; } const plugins = this.findByType(type); this._byTypeCache[type] = plugins; return Array.from(plugins); } - public atLeastOneByType(type: T["type"]): T[] { + public atLeastOneByType(type: T["type"]) { const list = this.byType(type); if (list.length === 0) { throw new Error(`There are no plugins by type "${type}".`); @@ -86,7 +88,7 @@ export class PluginsContainer { return list; } - public oneByType(type: T["type"]): T { + public oneByType(type: T["type"]) { const list = this.atLeastOneByType(type); if (list.length > 1) { throw new Error( @@ -125,7 +127,9 @@ export class PluginsContainer { delete this.plugins[name]; } - private findByType(type: T["type"]): T[] { - return Object.values(this.plugins).filter((pl): pl is T => pl.type === type) as T[]; + private findByType(type: T["type"]) { + return Object.values(this.plugins).filter( + (pl): pl is T => pl.type === type + ) as WithName[]; } } From 122232cd6abf3e2f6765dfd3f45d62085ce2337c Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 3 Dec 2024 13:31:31 +0100 Subject: [PATCH 03/28] fix(app-page-builder): create plugin loader components --- .../admin/components/EditorPluginsLoader.tsx | 138 ------------------ .../PluginLoaders/EditorPluginsLoader.tsx | 8 + .../PluginLoaders/RenderPluginsLoader.tsx | 8 + .../PluginLoaders/createPluginsLoader.tsx | 65 +++++++++ .../src/admin/plugins/routes.tsx | 128 +++++++--------- packages/app-page-builder/src/types.ts | 4 +- 6 files changed, 139 insertions(+), 212 deletions(-) delete mode 100644 packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx diff --git a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx deleted file mode 100644 index 0bb6d03f2b..0000000000 --- a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect, useReducer } from "react"; -import * as History from "history"; -import { plugins } from "@webiny/plugins"; -import { CircularProgress } from "@webiny/ui/Progress"; -import { PbPluginsLoader } from "~/types"; - -const globalState: State = { - render: false, - editor: false -}; - -// Since these plugins are loaded asynchronously, and some overrides might've been registered -// already by the developer (e.g. in the main App.tsx file), we only register new plugins. -// In other words, if the plugin with a particular name already exists, we skip its registration. - -interface State { - render?: boolean; - editor?: boolean; -} - -interface EditorPluginsLoaderProps { - location: History.Location; - children: React.ReactNode; -} - -export const EditorPluginsLoader = ({ children, location }: EditorPluginsLoaderProps) => { - const [loaded, setLoaded] = useReducer( - (state: State, newState: Partial) => ({ ...state, ...newState }), - globalState - ); - - const isEditorRoute = [ - "/page-builder/editor", - "/page-builder/block-editor", - "/page-builder/template-editor" - ].some(path => location.pathname.startsWith(path)); - - const loadPlugins = async () => { - const pbPlugins = plugins.byType("pb-plugins-loader"); - // load all editor admin plugins - const loadEditorPlugins = async () => - await Promise.all( - pbPlugins - .map(plugin => plugin.loadEditorPlugins && plugin.loadEditorPlugins()) - .filter(Boolean) - ); - // load all editor render plugins - const loadRenderPlugins = async () => - await Promise.all( - pbPlugins - .map(plugin => plugin.loadRenderPlugins && plugin.loadRenderPlugins()) - .filter(Boolean) - ); - - // If we are on pages list route, import plugins required to render the page content. - if (location.pathname.startsWith("/page-builder/pages") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on pages list route, import plugins required to render the page content. - if (location.pathname.startsWith("/page-builder/page-blocks") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on page templates list route, import plugins required to render the template content. - if (location.pathname.startsWith("/page-builder/page-templates") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on the Editor route, import plugins required to render both editor and preview. - if (isEditorRoute && !loaded.editor) { - const renderPlugins = !loaded.render ? await loadRenderPlugins() : []; - const editorAdminPlugins = await loadEditorPlugins(); - // merge both editor admin and render plugins - const editorRenderPlugins = [...editorAdminPlugins, ...renderPlugins].filter(Boolean); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(editorRenderPlugins, { skipExisting: true }); - - globalState.editor = true; - globalState.render = true; - - setLoaded({ editor: true, render: true }); - } - }; - - useEffect(() => { - loadPlugins(); - }, [location.pathname]); - - /** - * This condition is for the list of pages. - * Page can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/pages") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for the list of page blocks. - * Blocks can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/page-blocks") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for the list of page templates. - * Page template can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/page-templates") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for editing of the selected page/template. - */ - if (isEditorRoute && loaded.editor) { - return children as unknown as React.ReactElement; - } - - return ; -}; diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx new file mode 100644 index 0000000000..9a469d77e6 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx @@ -0,0 +1,8 @@ +import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader"; + +export const EditorPluginsLoader = createPluginsLoader({ + type: "pb-editor-page-element", + factory: plugin => { + return plugin.loadEditorPlugins ? plugin.loadEditorPlugins() : undefined; + } +}); diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx new file mode 100644 index 0000000000..ce354e95df --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx @@ -0,0 +1,8 @@ +import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader"; + +export const RenderPluginsLoader = createPluginsLoader({ + type: "pb-render-page-element", + factory: plugin => { + return plugin.loadRenderPlugins ? plugin.loadRenderPlugins() : undefined; + } +}); diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx new file mode 100644 index 0000000000..52c6bdb382 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import type { Plugin } from "@webiny/plugins/types"; +import { GenericRecord } from "@webiny/app/types"; +import { plugins } from "@webiny/plugins"; +import { CircularProgress } from "@webiny/ui/Progress"; +import type { PbEditorPageElementPlugin, PbPluginsLoader, PbRenderElementPlugin } from "~/types"; + +export interface CreatePluginsLoaderParams { + // Plugin type + type: PbRenderElementPlugin["type"] | PbEditorPageElementPlugin["type"]; + // Plugin factory + factory: (plugin: PbPluginsLoader) => Promise | undefined; +} + +const globalCache: GenericRecord = {}; + +export interface PluginsLoaderProps { + children: React.ReactNode; +} + +export const createPluginsLoader = ({ type, factory }: CreatePluginsLoaderParams) => { + const PluginsLoader = ({ children }: PluginsLoaderProps) => { + const [loaded, setLoaded] = useState(false); + + const loadPlugins = async () => { + const pluginsLoaders = plugins.byType("pb-plugins-loader"); + + const lazyLoadedPlugins = await Promise.all( + pluginsLoaders.map(plugin => factory(plugin)).filter(Boolean) + ); + + // Here comes an awkward hack: there's a chance that a user registered some custom plugins through React, + // and they're already in the registry. But we want to make sure that user plugins are applied _after_ the lazy-loaded + // plugins, loaded via the `pb-plugins-loader`. To achieve that, we unregister existing plugins, and register them + // _after_ the lazy-loaded ones. + + const existingPlugins = plugins.byType(type); + + existingPlugins.forEach(plugin => { + plugins.unregister(plugin.name); + }); + + // Register lazy-loaded plugins first. + plugins.register(lazyLoadedPlugins); + plugins.register(existingPlugins); + }; + + useEffect(() => { + if (!globalCache[type]) { + loadPlugins().then(() => { + globalCache[type] = true; + setLoaded(true); + }); + } else { + setLoaded(true); + } + }, []); + + return loaded ? <>{children} : ; + }; + + PluginsLoader.displayName = `PluginsLoader<${type}>`; + + return PluginsLoader; +}; diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index ab702c5108..8cd18ad6eb 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -5,7 +5,6 @@ import { AdminLayout } from "@webiny/app-admin/components/AdminLayout"; import { SecureRoute } from "@webiny/app-security/components"; import { RoutePlugin } from "@webiny/app/types"; import { CompositionScope } from "@webiny/react-composition"; -import { EditorPluginsLoader } from "../components/EditorPluginsLoader"; import Categories from "../views/Categories/Categories"; import Menus from "../views/Menus/Menus"; @@ -17,6 +16,8 @@ import PageTemplates from "~/admin/views/PageTemplates/PageTemplates"; import { PageEditor } from "~/pageEditor/Editor"; import { BlockEditor } from "~/blockEditor/Editor"; import { TemplateEditor } from "~/templateEditor/Editor"; +import { RenderPluginsLoader } from "~/admin/components/PluginLoaders/RenderPluginsLoader"; +import { EditorPluginsLoader } from "~/admin/components/PluginLoaders/EditorPluginsLoader"; const ROLE_PB_CATEGORY = "pb.category"; const ROLE_PB_MENUS = "pb.menu"; @@ -30,16 +31,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( ( + element={ - )} + } /> ) }, @@ -48,16 +48,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( ( + element={ - )} + } /> ) }, @@ -66,20 +65,19 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( ( + element={ - + - + - )} + } /> ) }, @@ -88,20 +86,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( { - return ( - - - - - - - - - ); - }} + element={ + + + + + + + + + } /> ) }, @@ -110,20 +105,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( { - return ( - - - - - - - - - ); - }} + element={ + + + + + + + + + } /> ) }, @@ -132,20 +124,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( { - return ( - - - - - - - - - ); - }} + element={ + + + + + + + + + } /> ) }, @@ -154,16 +143,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( ( + element={ - )} + } /> ) }, @@ -172,18 +160,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( ( + element={ - + - + - )} + } /> ) }, @@ -192,20 +179,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( { - return ( - - - - - - - - - ); - }} + element={ + + + + + + + + + } /> ) } diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index 03eb871c8c..983a94fd73 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -312,8 +312,8 @@ export interface PbTheme { } export type PbPluginsLoader = Plugin & { - loadEditorPlugins?: () => Promise; - loadRenderPlugins?: () => Promise; + loadEditorPlugins?: () => Promise | undefined; + loadRenderPlugins?: () => Promise | undefined; }; export type PbThemePlugin = Plugin & { From 2cb9a8a7ba892755e603e6dfb215444bc41b1714 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 3 Dec 2024 13:51:11 +0100 Subject: [PATCH 04/28] fix(app-page-builder): export loader components --- packages/app-page-builder/src/admin/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app-page-builder/src/admin/index.ts b/packages/app-page-builder/src/admin/index.ts index 03cfdd1476..e5467bb379 100644 --- a/packages/app-page-builder/src/admin/index.ts +++ b/packages/app-page-builder/src/admin/index.ts @@ -5,3 +5,6 @@ export * from "./hooks/usePageBuilderSettings"; export * from "./hooks/useConfigureWebsiteUrl"; export * from "./hooks/useSiteStatus"; export * from "./hooks/useAdminPageBuilder"; + +export * from "./components/PluginLoaders/EditorPluginsLoader"; +export * from "./components/PluginLoaders/RenderPluginsLoader"; From d2a02573dbddabfcfff99e1e8877bea1897fb55d Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Thu, 5 Dec 2024 05:44:32 +0100 Subject: [PATCH 05/28] fix: introduce `useLoader` hook (#4424) --- .github/workflows/pullRequests.yml | 36 +++++----- .github/workflows/wac/pullRequests.wac.ts | 5 +- .../extractPeLoaderDataFromHtml.test.ts | 45 ++++++++++++ .../api-prerendering-service/package.json | 2 + .../src/render/extractPeLoaderDataFromHtml.ts | 69 +++++++++++++++++++ .../src/render/renderUrl.ts | 31 +++++---- .../src/render/types.ts | 16 ++++- .../src/contexts/PageElements.tsx | 15 ++-- .../src/hooks/useLoader.ts | 61 ++++++++++++++++ .../src/hooks/useLoader/ILoaderCache.ts | 6 ++ .../src/hooks/useLoader/NullLoaderCache.ts | 19 +++++ .../src/hooks/useLoader/createObjectHash.ts | 17 +++++ .../app-page-builder-elements/src/index.ts | 1 + .../app-page-builder-elements/src/types.ts | 2 + packages/app-page-builder/src/PageBuilder.tsx | 9 ++- .../ResponsiveElementsProvider.tsx | 9 ++- .../PageBuilder/PageBuilderContext.tsx | 6 +- .../PageBuilder/PageElementsProvider.tsx | 9 ++- .../contexts/EditorPageElementsProvider.tsx | 6 ++ .../elementSettings/save/SaveDialog.tsx | 9 ++- packages/app-website/src/LinkPreload.tsx | 33 ++++++--- packages/app-website/src/Website.tsx | 9 ++- .../src/utils/WebsiteLoaderCache.ts | 37 ++++++++++ .../WebsiteLoaderCache/PeLoaderHtmlCache.ts | 26 +++++++ .../ddbPutItemConditionalCheckFailed.js | 12 ++++ .../gracefulPulumiErrorHandlers/index.js | 3 +- .../src/promptQuestions.ts | 5 +- 27 files changed, 437 insertions(+), 61 deletions(-) create mode 100644 packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts create mode 100644 packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache.ts create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts create mode 100644 packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index 7326019c8c..e4dfa42ee2 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: @@ -188,7 +190,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) @@ -214,7 +216,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: @@ -242,7 +244,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: @@ -263,7 +265,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 != '[]' @@ -361,7 +363,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: @@ -381,7 +383,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 != '[]' @@ -478,7 +480,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: @@ -499,7 +501,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 }} @@ -608,7 +610,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: @@ -629,7 +631,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 7cbab4bb82..6dea090644 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 0000000000..967a670437 --- /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.

  • `; diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json index 7ef4468742..dc3ba82289 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", @@ -34,6 +35,7 @@ "srcset": "^4.0.0" }, "devDependencies": { + "@types/he": "^1.2.3", "@types/object-hash": "^2.2.1", "@types/puppeteer-core": "7.0.4", "@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 0000000000..1618e550a2 --- /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 c880c185b6..de07395b8e 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 { generateAlphaNumericId } from "@webiny/utils/generateId"; 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 98731bc11a..a4976ffa91 100644 --- a/packages/api-prerendering-service/src/render/types.ts +++ b/packages/api-prerendering-service/src/render/types.ts @@ -25,11 +25,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 e05dd3fc65..42b994ff4d 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 0000000000..095b13e4bb --- /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 0000000000..31abcbdc53 --- /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 0000000000..5649d75f19 --- /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 0000000000..f619c11e20 --- /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 4865e5ee8f..19ca8705fd 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 a96ec8f6a1..19ea0dac7e 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 04232ee09a..2ee9e4ab1b 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"; import { ConvertIconSettings as EditorConvertIconSettings } from "~/editor/prepareEditorContent/ConvertIconSettings"; import { ConvertIconSettings as RendererConvertIconSettings } from "~/render/plugins/elementSettings/icon"; @@ -26,8 +27,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 dc331449ae..d3481e3990 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 5caadd3d91..0eda5c49f3 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 7c6e2cce40..9c390c040e 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 c1a3661669..8531ad23b5 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 41d07a3bf1..8a7035ce9d 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 82a6fae34a..594e56dc79 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 cd6dabbad8..5b917e0845 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 0000000000..56d31a0326 --- /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 0000000000..10b3b83a1b --- /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 0000000000..db5be4d75b --- /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 9d8c0c559b..ddf536f448 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 0d526db9cc..fc5f949fb1 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" } ] }, From 728dbfdeb8006723e5cd6e34f35ff7df280fcf52 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Thu, 5 Dec 2024 07:01:54 +0100 Subject: [PATCH 06/28] fix: add "extension" suffix to the label --- packages/cli-plugin-extensions/src/promptQuestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts index fc5f949fb1..5c6bda6945 100644 --- a/packages/cli-plugin-extensions/src/promptQuestions.ts +++ b/packages/cli-plugin-extensions/src/promptQuestions.ts @@ -23,7 +23,7 @@ export const promptQuestions: QuestionCollection = [ choices: [ { name: "Admin extension", value: "admin" }, { name: "API extension", value: "api" }, - { name: "Page Builder element", value: "pbElement" }, + { name: "Page Builder element extension", value: "pbElement" }, { name: "Website extension", value: "website" } ] }, From d16b581ca59c27df7d19541ba4001eff0fac6b1e Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Thu, 12 Dec 2024 23:55:03 +0100 Subject: [PATCH 07/28] fix: compress cached loader data (#4435) --- .../extractPeLoaderDataFromHtml.test.ts | 34 ++----------------- .../src/render/extractPeLoaderDataFromHtml.ts | 6 +--- packages/app-website/package.json | 1 + packages/app-website/src/Website.tsx | 8 ++--- .../src/utils/WebsiteLoaderCache.ts | 9 +++-- .../WebsiteLoaderCache/PeLoaderHtmlCache.ts | 22 ++++++++++-- 6 files changed, 36 insertions(+), 44 deletions(-) diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts index 967a670437..ae5cdb8191 100644 --- a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts +++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts @@ -4,42 +4,14 @@ describe("extractPeLoaderDataFromHtml Tests", () => { it("must detect pe-loader-data-cache tags in given HTML", async () => { const results = extractPeLoaderDataFromHtml(TEST_STRING); + // The value is not decompressed, so it's still a 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" - } - ] + value: "pe_NobwRAJgpgzgxgJwJYAcAuSD2A7MAuMAFQAsoACAMQEMAbOHMgRjIHcqYyrsyoAPFKNghUARjXI0qAV2xxiZGAE8YaKAFsyKZADcqqmorLRtUGpgEROQsmq5SAZlThopCKJZGGAyiidQAGkauSNgA5mQATAAMUQBsALTRUQCcAHRkAPLcEQAcZF5Q6OoiUAiRMTkANJS09NzMJXBUauRopGT2SAgqmjp6porxxqbm7mQ0SACOUkgQ8fZSpuPSsvImxEhw4mRomGSheyG7ZJgIIkhonAiYMpZt5ACiVAhtqWCVYLP4YACsUMkQKIQZJ/YSxZIgn72ADsKQgjCgIneYGwzSg32odAYjGRLCQAGtUO4kFRvsQ0GgUDA8AB6GmCVJ4wkWEmpU6hGlMpA0zF1AD6OIAvpVwNB4Mh0FhcAReQxkmQkBwqDsWJh4ioqKFyNc4PioJcxUhQtgxlxLLZsA4nC43B5vL44AEOqcdu03BNRNszQoqPZWgguDAUKdLph7D79BNVErrPcyAARAMHbhBvyIX2XI57U7nNBvD5fAh/AFAkHuKjg5LQgDMVBycIRcGRqJaGNqctxBKJEBJZIpVNp9OwjK7LKobIQHK5PPb2D5yTAwtFsEQqAwOG+AHULvI46IkFHDMcJvZM9hjjmLicTGUfgAWGz65BwHY4DgACkYjGSlRiUXGIgAJTxPEyq2DAHBQNMSC6OI56vpwZA1tCZAAFb6hMJplGYVDQJYeJtJo7AwIIWrdNUiBQCw1Q0FIoShJq5DegspggbK3AABJQFQ2iGE03AnpcainK0xBcCqmyieQviKDhlhhq65AmrwlxbJgJE9KMAbrqiNBkOsmziNUcbxqYaDKgAkgAamQXE8Yo1R6CcJrxG0XR3O09AqPmnwQN8xaAsCoIVhC0J3hAIgNu4zZom2WKcdxvGdsyxKkgQ5KUtSdIMlyY4TlOXYzvFfJ2UlS6QCuEo6d8Xjmd0GwoFYlheFIAhlKVhgAEqYLq+pkG4WiwIIlzKgsNAGP1UBSDAnr+oGwYvHoUoKMoqgaIaxpjMcJEINokmcONZBPC88iXpcJruEqHAsKYensIpZAALKYAw3qPc8MDpCQioqmqGpavpUAbFs5AAMMAEUAEMAAGQwAKWQ9BqMGJHyeGcYtW1tmJYYOp6pc74iC9KilIBTX5HVMANWQ75U6gZMQzD8OsAeelQCY55SLQE0DZIjo1PFZA/gLdTY/Z5OJpqOA+YWvz/IFySxOWlbVjkESAgCCLoh8LbogQtUfQ1yXdr26X9llQ4jilPbjuynKFT4fj+HyBv1agi4ALpAA==" } ]); }); }); -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.

  • `; +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.

  • `; diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts index 1618e550a2..cff831707b 100644 --- a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts +++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts @@ -1,5 +1,4 @@ import { PeLoaderCacheEntry } from "./types"; -import he from "he"; const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => { const regex = @@ -14,10 +13,7 @@ const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null = 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 { key, value }; } return null; diff --git a/packages/app-website/package.json b/packages/app-website/package.json index d986bdb728..b5be83ee0f 100644 --- a/packages/app-website/package.json +++ b/packages/app-website/package.json @@ -25,6 +25,7 @@ "apollo-link": "^1.2.14", "apollo-link-batch-http": "^1.2.14", "graphql-tag": "^2.12.6", + "lz-string": "^1.5.0", "react": "18.2.0", "react-dom": "18.2.0", "react-helmet": "^6.1.0", diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx index 5b917e0845..229798fdff 100644 --- a/packages/app-website/src/Website.tsx +++ b/packages/app-website/src/Website.tsx @@ -18,11 +18,11 @@ export interface WebsiteProps extends AppProps { const PageBuilderProviderHOC: Decorator< GenericComponent<{ children: React.ReactNode }> > = PreviousProvider => { - const websiteLoaderCache = useMemo(() => { - return new WebsiteLoaderCache(); - }, []); - return function PageBuilderProviderHOC({ children }) { + const websiteLoaderCache = useMemo(() => { + return new WebsiteLoaderCache(); + }, []); + return ( {children} diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts index 56d31a0326..bb1a7efcba 100644 --- a/packages/app-website/src/utils/WebsiteLoaderCache.ts +++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts @@ -1,6 +1,6 @@ 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"; +import { PeLoaderHtmlCache } from "./WebsiteLoaderCache/PeLoaderHtmlCache"; export class WebsiteLoaderCache implements ILoaderCache { private loaderCache: Record = {}; @@ -19,7 +19,12 @@ export class WebsiteLoaderCache implements ILoaderCache { return this.loaderCache[key]; } - write(key: string, value: TData) { + write(key: string, rawValue: TData) { + // We assume it's compressed data if the value is a string. + const value = PeLoaderHtmlCache.isCompressedData(rawValue) + ? PeLoaderHtmlCache.decompressData(rawValue as string) + : rawValue; + this.loaderCache[key] = value; if (isPrerendering()) { diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts index 10b3b83a1b..87cbe6d811 100644 --- a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts +++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts @@ -1,3 +1,7 @@ +import lzString from "lz-string"; + +const COMPRESSED_DATA_PREFIX = "pe_"; + export class PeLoaderHtmlCache { static read(key: string) { const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`); @@ -11,16 +15,30 @@ export class PeLoaderHtmlCache { } try { - return JSON.parse(cachedResultElementValue) as TData; + return PeLoaderHtmlCache.decompressData(cachedResultElementValue) as TData; } catch { return null; } } static write(key: string, value: TData) { - const html = `( value )}'>`; document.body.insertAdjacentHTML("beforeend", html); } + + static compressData(data: TData) { + return COMPRESSED_DATA_PREFIX + lzString.compressToBase64(JSON.stringify(data)); + } + + static decompressData(data: string) { + return JSON.parse( + lzString.decompressFromBase64(data.replace(COMPRESSED_DATA_PREFIX, "")) as string + ); + } + + static isCompressedData(data: TData) { + return typeof data === "string" && data.startsWith(COMPRESSED_DATA_PREFIX); + } } From 90d58540d57c9272af5b6642d9d9c5a310d3e558 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Fri, 13 Dec 2024 08:11:37 +0100 Subject: [PATCH 08/28] fix:replace `render` prop with `renderer` (#4444) --- .../src/plugins/PbEditorPageElementPlugin.tsx | 27 +++++++++++++------ .../src/plugins/PbRenderElementPlugin.tsx | 16 ++++++++--- .../src/plugins/PbRenderElementPlugin.tsx | 16 ++++++++--- .../src/utils/legacyPluginToReactComponent.ts | 11 +++++--- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx index ba8f4247c5..7c9e065ede 100644 --- a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx +++ b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx @@ -1,8 +1,10 @@ import type { PbEditorPageElementPlugin as BasePbEditorPageElementPlugin } from "~/types"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; + import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbEditorPageElementPlugin = legacyPluginToReactComponent< - Pick< +export interface PbEditorPageElementPluginProps + extends Pick< BasePbEditorPageElementPlugin, | "elementType" | "toolbar" @@ -10,15 +12,24 @@ export const PbEditorPageElementPlugin = legacyPluginToReactComponent< | "target" | "settings" | "create" - | "render" | "canDelete" | "canReceiveChildren" | "onReceived" | "onChildDeleted" | "onCreate" | "renderElementPreview" - > ->({ - pluginType: "pb-editor-page-element", - componentDisplayName: "PbEditorPageElementPlugin" -}); + > { + renderer: Renderer; +} + +export const PbEditorPageElementPlugin = + legacyPluginToReactComponent({ + pluginType: "pb-editor-page-element", + componentDisplayName: "PbEditorPageElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } + }); diff --git a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx index cedbadab56..90e79829c9 100644 --- a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx +++ b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx @@ -1,9 +1,17 @@ import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "~/types"; import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbRenderElementPlugin = legacyPluginToReactComponent< - Pick ->({ +interface PbRenderElementPluginProps extends Pick { + renderer: BasePbRenderElementPlugin["render"]; +} + +export const PbRenderElementPlugin = legacyPluginToReactComponent({ pluginType: "pb-render-page-element", - componentDisplayName: "PbRenderElementPlugin" + componentDisplayName: "PbRenderElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } }); diff --git a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx index ffaa7fc512..0c07c47c2a 100644 --- a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx +++ b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx @@ -1,9 +1,17 @@ import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "@webiny/app-page-builder/types"; import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbRenderElementPlugin = legacyPluginToReactComponent< - Pick ->({ +interface PbRenderElementPluginProps extends Pick { + renderer: BasePbRenderElementPlugin["render"]; +} + +export const PbRenderElementPlugin = legacyPluginToReactComponent({ pluginType: "pb-render-page-element", - componentDisplayName: "PbRenderElementPlugin" + componentDisplayName: "PbRenderElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } }); diff --git a/packages/app/src/utils/legacyPluginToReactComponent.ts b/packages/app/src/utils/legacyPluginToReactComponent.ts index 3d93cc3915..5da2e3f999 100644 --- a/packages/app/src/utils/legacyPluginToReactComponent.ts +++ b/packages/app/src/utils/legacyPluginToReactComponent.ts @@ -1,16 +1,21 @@ import React from "react"; import { useRegisterLegacyPlugin } from "~/hooks/useRegisterLegacyPlugin"; -export interface LegacyPluginToReactComponentParams { +export interface LegacyPluginToReactComponentParams> { pluginType: string; componentDisplayName: string; + mapProps?: (props: TProps) => TProps; } export const legacyPluginToReactComponent = function >( - params: LegacyPluginToReactComponentParams + params: LegacyPluginToReactComponentParams ) { const Component: React.ComponentType = props => { - useRegisterLegacyPlugin({ ...props, type: params.pluginType }); + const plugin = Object.assign( + { type: params.pluginType }, + params.mapProps ? params.mapProps(props) : props + ); + useRegisterLegacyPlugin(plugin); return null; }; From 456b9bbc1faebcb1e4926126fed92da70f18cbe5 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Fri, 13 Dec 2024 09:26:25 +0100 Subject: [PATCH 09/28] fix: improve import identifier uniqueness (#4437) --- .../cli-plugin-extensions/src/extensions/AdminExtension.ts | 7 +++---- .../cli-plugin-extensions/src/extensions/ApiExtension.ts | 7 ++++--- .../src/extensions/PbElementExtension.ts | 7 +++---- .../src/extensions/WebsiteExtension.ts | 7 +++---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts index 75136f5e47..87575cfb6f 100644 --- a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class AdminExtension extends AbstractExtension { async link() { @@ -38,12 +39,10 @@ export class AdminExtension extends AbstractExtension { } private async addPluginToAdminApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "admin", "src", "Extensions.tsx"); - const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1); - const componentName = ucFirstName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName; diff --git a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts index 776374c84a..35e6c26923 100644 --- a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { ArrayLiteralExpression, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class ApiExtension extends AbstractExtension { async link() { @@ -38,11 +39,11 @@ export class ApiExtension extends AbstractExtension { } private async addPluginToApiApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "api", "graphql", "src", "extensions.ts"); - const extensionFactory = name + "ExtensionFactory"; + const { packageName } = this.params; + const extensionFactory = Case.pascal(packageName) + "ExtensionFactory"; + const importName = "{ createExtension as " + extensionFactory + " }"; const importPath = packageName; diff --git a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts index 9ca965948c..0f7be7d90c 100644 --- a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class PbElementExtension extends AbstractExtension { async link() { @@ -46,12 +47,10 @@ export class PbElementExtension extends AbstractExtension { } private async addPluginToApp(app: "admin" | "website") { - const { name: extensionName, packageName } = this.params; - const extensionsFilePath = path.join("apps", app, "src", "Extensions.tsx"); - const ucFirstExtName = extensionName.charAt(0).toUpperCase() + extensionName.slice(1); - const componentName = ucFirstExtName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName + "/src/" + app; diff --git a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts index 42326e3bb9..875d29f010 100644 --- a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class WebsiteExtension extends AbstractExtension { async link() { @@ -38,12 +39,10 @@ export class WebsiteExtension extends AbstractExtension { } private async addPluginToWebsiteApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "website", "src", "Extensions.tsx"); - const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1); - const componentName = ucFirstName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName; From 63a73f071e0bac0af414674b74fdda577e04b639 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Mon, 16 Dec 2024 10:51:16 +0100 Subject: [PATCH 10/28] fix: add error handling capabilities (#4447) --- .../src/hooks/useLoader.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts index 095b13e4bb..e96795b994 100644 --- a/packages/app-page-builder-elements/src/hooks/useLoader.ts +++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts @@ -2,21 +2,22 @@ import { useEffect, useMemo, useState, type DependencyList } from "react"; import { createObjectHash } from "./useLoader/createObjectHash"; import { useRenderer } from ".."; -export interface RendererLoader { +export interface RendererLoader { data: TData | null; loading: boolean; cacheHit: boolean; cacheKey: null | string; + error: null | TError; } export interface UseLoaderOptions { cacheKey?: DependencyList; } -export function useLoader( +export function useLoader( loaderFn: () => Promise, options?: UseLoaderOptions -): RendererLoader { +): RendererLoader { const { getElement, loaderCache } = useRenderer(); const element = getElement(); @@ -29,15 +30,16 @@ export function useLoader( return loaderCache.read(cacheKey); }, [cacheKey]); - const [loader, setLoader] = useState>( + const [loader, setLoader] = useState>( cachedData ? { data: cachedData, loading: false, cacheHit: true, - cacheKey + cacheKey, + error: null } - : { data: null, loading: true, cacheHit: false, cacheKey: null } + : { data: null, loading: true, cacheHit: false, cacheKey: null, error: null } ); useEffect(() => { @@ -46,15 +48,25 @@ export function useLoader( } if (cachedData) { - setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true }); + setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true, error: null }); return; } - setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false }); - loaderFn().then(data => { - loaderCache.write(cacheKey, data); - setLoader({ data, loading: false, cacheKey, cacheHit: false }); + setLoader({ + data: loader.data, + error: loader.error, + loading: true, + cacheKey, + cacheHit: false }); + loaderFn() + .then(data => { + loaderCache.write(cacheKey, data); + setLoader({ data, error: null, loading: false, cacheKey, cacheHit: false }); + }) + .catch(error => { + setLoader({ data: null, error, loading: false, cacheKey, cacheHit: false }); + }); }, [cacheKey]); return loader; From 4ff8675a29fb2873bf692644bd854fe6efa3c72d Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 16 Dec 2024 10:57:05 +0100 Subject: [PATCH 11/28] fix: remove exports section --- .../cli-plugin-extensions/templates/pbElement/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cli-plugin-extensions/templates/pbElement/package.json b/packages/cli-plugin-extensions/templates/pbElement/package.json index 016ab73ce4..5857ab2dc1 100644 --- a/packages/cli-plugin-extensions/templates/pbElement/package.json +++ b/packages/cli-plugin-extensions/templates/pbElement/package.json @@ -1,9 +1,5 @@ { "name": "PACKAGE_NAME", - "exports": { - "./admin": "./src/admin.tsx", - "./website": "./src/website.tsx" - }, "version": "1.0.0", "keywords": [ "webiny-extension", From e21409e8a04e7517161a9b606af1868d7030a446 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Mon, 16 Dec 2024 11:03:30 +0100 Subject: [PATCH 12/28] fix: export utils from `@webiny/app` --- packages/app-admin/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts index 2746002db4..ceaecc904b 100644 --- a/packages/app-admin/src/index.ts +++ b/packages/app-admin/src/index.ts @@ -63,3 +63,11 @@ export { AaclPermission } from "@webiny/app-wcp/types"; export { useTheme, ThemeProvider } from "@webiny/app-theme"; export * from "@webiny/app/renderApp"; + +// Exporting chosen utils from `@webiny/app` package. +export * from "@webiny/app/utils/getApiUrl"; +export * from "@webiny/app/utils/getGqlApiUrl"; +export * from "@webiny/app/utils/getHeadlessCmsGqlApiUrl"; +export * from "@webiny/app/utils/getLocaleCode"; +export * from "@webiny/app/utils/getTenantId"; +export * from "@webiny/app/utils/isLocalhost"; From babec4ddb5747945eae468a34128cc167db90934 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Tue, 17 Dec 2024 08:46:07 +0100 Subject: [PATCH 13/28] fix: create settings entry when new locale is created (#4446) --- .../__tests__/graphql/i18n.ts | 15 ++ .../__tests__/settings.test.ts | 40 ++++- .../__tests__/useGqlHandler.ts | 22 +-- .../src/plugins/crud/index.ts | 167 ++++++++++-------- packages/api-i18n/src/graphql/context.ts | 22 ++- packages/api-i18n/src/types.ts | 4 + 6 files changed, 180 insertions(+), 90 deletions(-) create mode 100644 packages/api-form-builder/__tests__/graphql/i18n.ts diff --git a/packages/api-form-builder/__tests__/graphql/i18n.ts b/packages/api-form-builder/__tests__/graphql/i18n.ts new file mode 100644 index 0000000000..f33ffc38db --- /dev/null +++ b/packages/api-form-builder/__tests__/graphql/i18n.ts @@ -0,0 +1,15 @@ +export const CREATE_LOCALE = /* GraphQL */ ` + mutation CreateI18NLocale($data: I18NLocaleInput!) { + i18n { + createI18NLocale(data: $data) { + data { + code + } + error { + message + code + } + } + } + } +`; diff --git a/packages/api-form-builder/__tests__/settings.test.ts b/packages/api-form-builder/__tests__/settings.test.ts index 764543af83..48a100cc22 100644 --- a/packages/api-form-builder/__tests__/settings.test.ts +++ b/packages/api-form-builder/__tests__/settings.test.ts @@ -1,7 +1,8 @@ import useGqlHandler from "./useGqlHandler"; +import { GET_SETTINGS } from "~tests/graphql/formBuilderSettings"; describe("Settings Test", () => { - const { getSettings, updateSettings, install, isInstalled } = useGqlHandler(); + const { getSettings, updateSettings, install, createI18NLocale, isInstalled } = useGqlHandler(); test(`Should not be able to get & update settings before "install"`, async () => { // Should not have any settings without install @@ -154,4 +155,41 @@ describe("Settings Test", () => { } }); }); + + test(`Should be able to get & update settings after in a new locale`, async () => { + // Let's install the `Form builder` + await install({ domain: "http://localhost:3001" }); + + await createI18NLocale({ data: { code: "de-DE" } }); + + const { invoke } = useGqlHandler(); + + // Had to do it via `invoke` directly because this way it's possible to + // set the locale header. Wasn't easily possible via the `getSettings` helper. + const [newLocaleFbSettings] = await invoke({ + body: { query: GET_SETTINGS }, + headers: { + "x-i18n-locale": "default:de-DE;content:de-DE;" + } + }); + + // Settings should exist in the newly created locale. + expect(newLocaleFbSettings).toEqual({ + data: { + formBuilder: { + getSettings: { + data: { + domain: null, + reCaptcha: { + enabled: null, + secretKey: null, + siteKey: null + } + }, + error: null + } + } + } + }); + }); }); diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts index 0d6a810846..e5fc524dd4 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.ts +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -8,8 +8,12 @@ import i18nContext from "@webiny/api-i18n/graphql/context"; import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; import { SecurityIdentity, SecurityPermission } from "@webiny/api-security/types"; import { createFormBuilder } from "~/index"; +import { createI18NGraphQL } from "@webiny/api-i18n/graphql"; + // Graphql import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings"; +import { CREATE_LOCALE } from "./graphql/i18n"; + import { GET_SETTINGS, INSTALL, @@ -41,11 +45,7 @@ import { PluginCollection } from "@webiny/plugins/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; -import { - CmsParametersPlugin, - createHeadlessCmsContext, - createHeadlessCmsGraphQL -} from "@webiny/api-headless-cms"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { FormBuilderStorageOperations } from "~/types"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import { createPageBuilderContext } from "@webiny/api-page-builder"; @@ -83,14 +83,9 @@ export default (params: UseGqlHandlerParams = {}) => { graphqlHandlerPlugins(), ...createTenancyAndSecurity({ permissions, identity }), i18nContext(), + createI18NGraphQL(), i18nStorage.storageOperations, mockLocalesPlugins(), - new CmsParametersPlugin(async () => { - return { - locale: "en-US", - type: "manage" - }; - }), createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), createHeadlessCmsGraphQL(), createPageBuilderContext({ @@ -228,6 +223,11 @@ export default (params: UseGqlHandlerParams = {}) => { }, async exportFormSubmissions(variables: Record) { return invoke({ body: { query: EXPORT_FORM_SUBMISSIONS, variables } }); + }, + + // Locales. + async createI18NLocale(variables: Record) { + return invoke({ body: { query: CREATE_LOCALE, variables } }); } }; }; diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts index a4df2a2e98..5a20f2300b 100644 --- a/packages/api-form-builder/src/plugins/crud/index.ts +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -15,94 +15,107 @@ export interface CreateFormBuilderCrudParams { export default (params: CreateFormBuilderCrudParams) => { const { storageOperations } = params; - return new ContextPlugin(async context => { - const getLocale = () => { - const locale = context.i18n.getContentLocale(); - if (!locale) { - throw new WebinyError( - "Missing locale on context.i18n locale in API Form Builder.", - "LOCALE_ERROR" - ); + return [ + new ContextPlugin(async context => { + const getLocale = () => { + const locale = context.i18n.getContentLocale(); + if (!locale) { + throw new WebinyError( + "Missing locale on context.i18n locale in API Form Builder.", + "LOCALE_ERROR" + ); + } + return locale; + }; + + const getIdentity = () => { + return context.security.getIdentity(); + }; + + const getTenant = () => { + return context.tenancy.getCurrentTenant(); + }; + + if (storageOperations.beforeInit) { + try { + await storageOperations.beforeInit(context); + } catch (ex) { + throw new WebinyError( + ex.message || + "Could not run before init in Form Builder storage operations.", + ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR", + { + ...ex + } + ); + } } - return locale; - }; - const getIdentity = () => { - return context.security.getIdentity(); - }; + const basePermissionsArgs = { + getIdentity, + fullAccessPermissionName: "fb.*" + }; + + const formsPermissions = new FormsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.form") + }); - const getTenant = () => { - return context.tenancy.getCurrentTenant(); - }; + const settingsPermissions = new SettingsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.settings") + }); - if (storageOperations.beforeInit) { + context.formBuilder = { + storageOperations, + ...createSystemCrud({ + getIdentity, + getTenant, + getLocale, + context + }), + ...createSettingsCrud({ + getTenant, + getLocale, + settingsPermissions, + context + }), + ...createFormsCrud({ + getTenant, + getLocale, + formsPermissions, + context + }), + ...createSubmissionsCrud({ + context, + formsPermissions + }) + }; + + if (!storageOperations.init) { + return; + } try { - await storageOperations.beforeInit(context); + await storageOperations.init(context); } catch (ex) { throw new WebinyError( - ex.message || "Could not run before init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR", + ex.message || "Could not run init in Form Builder storage operations.", + ex.code || "STORAGE_OPERATIONS_INIT_ERROR", { ...ex } ); } - } - - const basePermissionsArgs = { - getIdentity, - fullAccessPermissionName: "fb.*" - }; - - const formsPermissions = new FormsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.form") - }); + }), - const settingsPermissions = new SettingsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.settings") - }); - - context.formBuilder = { - storageOperations, - ...createSystemCrud({ - getIdentity, - getTenant, - getLocale, - context - }), - ...createSettingsCrud({ - getTenant, - getLocale, - settingsPermissions, - context - }), - ...createFormsCrud({ - getTenant, - getLocale, - formsPermissions, - context - }), - ...createSubmissionsCrud({ - context, - formsPermissions - }) - }; - - if (!storageOperations.init) { - return; - } - try { - await storageOperations.init(context); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not run init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_INIT_ERROR", - { - ...ex - } - ); - } - }); + // Once a new locale is created, we need to create a new settings entry for it. + new ContextPlugin(async context => { + context.i18n.locales.onLocaleAfterCreate.subscribe(async params => { + const { locale } = params; + await context.i18n.withLocale(locale, async () => { + return context.formBuilder.createSettings({}); + }); + }); + }) + ]; }; diff --git a/packages/api-i18n/src/graphql/context.ts b/packages/api-i18n/src/graphql/context.ts index 4aaa948073..9293ea3af4 100644 --- a/packages/api-i18n/src/graphql/context.ts +++ b/packages/api-i18n/src/graphql/context.ts @@ -239,6 +239,25 @@ const createBaseContextPlugin = () => { return results; }; + const withLocale: I18NContextObject["withLocale"] = async (locale, cb) => { + const initialLocale = getDefaultLocale(); + if (!initialLocale) { + return; + } + + setContentLocale(locale); + setCurrentLocale("default", locale); + + try { + // We have to await the callback, because, in case it's an async function, + // the `finally` block would get executed before the callback finishes. + return await cb(); + } finally { + setContentLocale(initialLocale); + setCurrentLocale("default", initialLocale); + } + }; + context.i18n = { ...context.i18n, getDefaultLocale, @@ -252,7 +271,8 @@ const createBaseContextPlugin = () => { reloadLocales, hasI18NContentPermission: () => hasI18NContentPermission(context), checkI18NContentPermission, - withEachLocale + withEachLocale, + withLocale }; }); }; diff --git a/packages/api-i18n/src/types.ts b/packages/api-i18n/src/types.ts index f0b83d43d0..407d6aea0b 100644 --- a/packages/api-i18n/src/types.ts +++ b/packages/api-i18n/src/types.ts @@ -44,6 +44,10 @@ export interface I18NContextObject { locales: I18NLocale[], cb: (locale: I18NLocale) => Promise ) => Promise; + withLocale: ( + locale: I18NLocale, + cb: () => Promise + ) => Promise; } export interface SystemInstallParams { From 49d4d9c75ca3af1cce9b4e8c021cf9836c5ce157 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Tue, 17 Dec 2024 17:03:49 +0100 Subject: [PATCH 14/28] fix: preload fonts (#4450) --- .../handlers/render/linkPreloading.test.ts | 8 +- .../render/handlers/render/renderUrl.test.ts | 8 +- .../src/render/defaultRenderUrlFunction.ts | 157 ++++++++++++++++ .../src/render/preloadCss.ts | 7 + .../src/render/preloadFonts.ts | 37 ++++ .../src/render/preloadJs.ts | 7 + .../src/render/renderUrl.ts | 169 +----------------- .../src/render/types.ts | 5 + 8 files changed, 233 insertions(+), 165 deletions(-) create mode 100644 packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts create mode 100644 packages/api-prerendering-service/src/render/preloadCss.ts create mode 100644 packages/api-prerendering-service/src/render/preloadFonts.ts create mode 100644 packages/api-prerendering-service/src/render/preloadJs.ts diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts index 9ce663a69f..88c106c507 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts index 93aa89569e..0da167a5dd 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts new file mode 100644 index 0000000000..6bbddfa5b9 --- /dev/null +++ b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts @@ -0,0 +1,157 @@ +import chromium from "@sparticuz/chromium"; +import puppeteer, { Browser, Page } from "puppeteer-core"; +import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; +import { RenderResult, RenderUrlCallableParams } from "./types"; +import { TagPathLink } from "~/types"; + +const windowSet = (page: Page, name: string, value: string | boolean) => { + page.evaluateOnNewDocument(` + Object.defineProperty(window, '${name}', { + get() { + return '${value}' + } + })`); +}; + +export interface File { + type: string; + body: any; + name: string; + meta: { + tags?: TagPathLink[]; + [key: string]: any; + }; +} + +export const defaultRenderUrlFunction = async ( + url: string, + params: RenderUrlCallableParams +): Promise => { + let browser!: Browser; + + try { + browser = await puppeteer.launch({ + args: chromium.args, + defaultViewport: chromium.defaultViewport, + executablePath: await chromium.executablePath(), + headless: chromium.headless, + ignoreHTTPSErrors: true + }); + + const browserPage = await browser.newPage(); + + // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process. + windowSet(browserPage, "__PS_RENDER__", true); + + const tenant = params.args.tenant; + if (tenant) { + console.log("Setting tenant (__PS_RENDER_TENANT__) to window object...."); + windowSet(browserPage, "__PS_RENDER_TENANT__", tenant); + } + + const locale = params.args.locale; + if (locale) { + console.log("Setting locale (__PS_RENDER_LOCALE__) to window object...."); + windowSet(browserPage, "__PS_RENDER_LOCALE__", locale); + } + + const renderResult: RenderResult = { + content: "", + meta: { + interceptedRequests: [], + apolloState: {}, + cachedData: { + apolloGraphQl: [], + peLoaders: [] + } + } + }; + + // Don't load these resources during prerender. + const skipResources = ["image"]; + await browserPage.setRequestInterception(true); + + browserPage.on("request", request => { + const issuedRequest = { + type: request.resourceType(), + url: request.url(), + aborted: false + }; + + if (skipResources.includes(issuedRequest.type)) { + issuedRequest.aborted = true; + request.abort(); + } else { + request.continue(); + } + + renderResult.meta.interceptedRequests.push(issuedRequest); + }); + + // TODO: should be a plugin. + browserPage.on("response", async response => { + const request = response.request(); + const url = request.url(); + if (url.includes("/graphql") && request.method() === "POST") { + const responses = (await response.json()) as Record; + const postData = JSON.parse(request.postData() as string); + const operations = Array.isArray(postData) ? postData : [postData]; + + for (let i = 0; i < operations.length; i++) { + const { query, variables } = operations[i]; + + // For now, we're doing a basic @ps(cache: true) match to determine if the + // cache was set true. In the future, if we start introducing additional + // parameters here, we should probably make this parsing smarter. + const mustCache = query.match(/@ps\((cache: true)\)/); + + if (mustCache) { + const data = Array.isArray(responses) ? responses[i].data : responses.data; + renderResult.meta.cachedData.apolloGraphQl.push({ + query, + variables, + data + }); + } + } + return; + } + }); + + // Load URL and wait for all network requests to settle. + await browserPage.goto(url, { waitUntil: "networkidle0" }); + + renderResult.content = await browserPage.content(); + + renderResult.meta.apolloState = await browserPage.evaluate(() => { + // @ts-expect-error + return window.getApolloState(); + }); + + renderResult.meta.cachedData.peLoaders = extractPeLoaderDataFromHtml(renderResult.content); + + return renderResult; + } finally { + if (browser) { + // We need to close all open pages first, to prevent browser from hanging when closed. + const pages = await browser.pages(); + for (const page of pages) { + await page.close(); + } + + // This is fixing an issue where the `await browser.close()` would hang indefinitely. + // The "inspiration" for this fix came from the following issue: + // https://github.com/Sparticuz/chromium/issues/85 + console.log("Killing browser process..."); + const childProcess = browser.process(); + if (childProcess) { + childProcess.kill(9); + } + + console.log("Browser process killed."); + } + } + + // There's no catch block here because errors are already being handled + // in the entrypoint function, located in `./index.ts` file. +}; diff --git a/packages/api-prerendering-service/src/render/preloadCss.ts b/packages/api-prerendering-service/src/render/preloadCss.ts new file mode 100644 index 0000000000..805967db6c --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadCss.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "./types"; + +export const preloadCss = (render: RenderResult): void => { + const regex = / { + const fontsRequests = render.meta.interceptedRequests.filter( + req => req.type === "font" && req.url + ); + + const preloadLinks: string = Array.from(fontsRequests) + .map(req => { + return ``; + }) + .join("\n"); + + // Inject the preload tags into the section + render.content = render.content.replace("", `${preloadLinks}`); +}; diff --git a/packages/api-prerendering-service/src/render/preloadJs.ts b/packages/api-prerendering-service/src/render/preloadJs.ts new file mode 100644 index 0000000000..63fae20d82 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadJs.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "~/render/types"; + +export const preloadJs = (render: RenderResult): void => { + const regex = /