From 30d818f3fbf91ef68d2e3bf976971fac3949556b Mon Sep 17 00:00:00 2001 From: Durrab Jami Khan Date: Thu, 11 Mar 2021 13:08:13 -0800 Subject: [PATCH] xarc-react-recoil added ssr support with store contains recoil atoms (#1840) --- .../xarc-react-recoil/src/common/index.tsx | 60 ++-- .../test/spec/index.spec.tsx | 24 +- samples/subapp2-poc/src/app.tsx | 7 +- samples/subapp2-poc/src/home.tsx | 7 - .../src/recoil-character-counter.tsx | 99 +++++++ samples/subapp2-poc/src/recoil-todo-app.tsx | 259 ++++++++++++++++++ samples/subapp2-poc/src/recoilTodo.tsx | 228 --------------- samples/subapp2-poc/src/server/routes.tsx | 6 +- 8 files changed, 420 insertions(+), 270 deletions(-) create mode 100644 samples/subapp2-poc/src/recoil-character-counter.tsx create mode 100644 samples/subapp2-poc/src/recoil-todo-app.tsx delete mode 100644 samples/subapp2-poc/src/recoilTodo.tsx diff --git a/packages/xarc-react-recoil/src/common/index.tsx b/packages/xarc-react-recoil/src/common/index.tsx index 147dbf408..7efc90a41 100644 --- a/packages/xarc-react-recoil/src/common/index.tsx +++ b/packages/xarc-react-recoil/src/common/index.tsx @@ -1,9 +1,11 @@ -/* eslint-disable no-console */ -import { SubAppDef, SubAppFeatureFactory, SubAppFeature, envHooks } from "@xarc/subapp"; +/* eslint-disable prefer-const */ +/* eslint-disable dot-notation */ +/* eslint-disable max-statements, complexity */ -import { RecoilRoot } from "recoil"; +import { SubAppDef, SubAppFeatureFactory, SubAppFeature } from "@xarc/subapp"; +import { atom, RecoilState, RecoilRoot } from "recoil"; -export * from "recoil"; +export * as Recoil from "recoil"; /** * options for recoil feature @@ -13,10 +15,18 @@ export type RecoilFeatureOptions = { * The React module. * * This is needed for the recoil feature to wrap subapp's component inside - * the Recoil Provider component. + * the recoil Provider component. */ React: Partial<{ createElement: unknown }>; + /** + * prepare recoil initial state + * + * @param initialState - when SSR sent initialState used, it will be passed. The client + * `prepare` can just return `{initialState}` as is. + * + * @returns Promise<{initialState: any}> + */ prepare(initialState: any): Promise; }; @@ -26,33 +36,25 @@ export type RecoilFeatureOptions = { export type RecoilFeature = SubAppFeature & { options: RecoilFeatureOptions; wrap: (_: any) => any; - RecoilRoot: typeof RecoilRoot; prepare: any; - atomsMap: any; + _store: any; }; /** - * Add support for Recoil to a subapp + * Add support for recoil to a subapp * * @param options - recoil feature options * @returns unknown */ - -/** - * @param options - recoil feature options - * @returns unknown - */ export function recoilFeature(options: RecoilFeatureOptions): SubAppFeatureFactory { const { createElement } = options.React; // eslint-disable-line const id = "state-provider"; const subId = "react-recoil"; - const add = (def: SubAppDef) => { - const subAppName = def.name; - const subapp = envHooks.getContainer().get(subAppName); + const add = (subapp: SubAppDef) => { const recoil: Partial = { id, subId }; subapp._features.recoil = recoil as SubAppFeature; + // wrap: callback to wrap component with recoil recoil.options = options; - recoil.RecoilRoot = RecoilRoot; recoil.wrap = ({ Component, store }) => { return ( @@ -60,25 +62,29 @@ export function recoilFeature(options: RecoilFeatureOptions): SubAppFeatureFacto ); }; + + recoil.prepare = options.prepare; + recoil._store = new Map(); recoil.execute = async function ({ input, csrData }) { + let initialState: any; + const props = csrData && (await csrData.getInitialState()); - const initialState = await options.prepare(props); + initialState = (await options.prepare(props)).initialState; - if (recoil.atomsMap === undefined) { - const atomsMap = {}; - if (initialState && initialState.atoms) { - initialState.atoms.forEach((state: any) => { - atomsMap[state.key] = state; - }); - recoil.atomsMap = atomsMap; + if (initialState) { + for (const value of Object.values(initialState.state)) { + if (value && value["key"] && !recoil._store.get(value["key"])) { + const state = atom({ key: value["key"], default: value["value"] }) as RecoilState; + recoil._store.set(value["key"], state); + } } } return { Component: () => this.wrap({ - Component: input.Component ?? subapp._getExport()?.Component, - store: recoil.atomsMap + Component: input.Component || subapp._getExport()?.Component, + store: recoil._store }), props: initialState }; diff --git a/packages/xarc-react-recoil/test/spec/index.spec.tsx b/packages/xarc-react-recoil/test/spec/index.spec.tsx index 0b793ffce..e2142efe4 100644 --- a/packages/xarc-react-recoil/test/spec/index.spec.tsx +++ b/packages/xarc-react-recoil/test/spec/index.spec.tsx @@ -6,12 +6,20 @@ import { expect } from "chai"; import { SubAppDef, SubAppContainer, envHooks } from "@xarc/subapp"; import { render, waitFor, screen } from "@testing-library/react"; import sinon from "sinon"; -import { recoilFeature, RecoilFeature, RecoilRoot } from "../../src/browser/index"; +import { recoilFeature, RecoilFeature } from "../../src/browser/index"; const { createElement } = React; // eslint-disable-line const mockPrepare = async initialState => { - return { atoms: { key: "atomKey", value: {} } }; + return { + initialState: { + state: { + todoListState: { key: "todoListState", value: [] }, + todoListFilterState: { key: "todoListFilterState", value: "Show All" } + }, + selectors: {} + } + }; }; const options = { @@ -51,8 +59,6 @@ describe("reactRecoilFeature", function () { factory.add(def); const recoil: Partial = def._features.recoil; - expect(recoil.RecoilRoot).equal(RecoilRoot); - expect(recoil.wrap).to.be.an("function"); expect(recoil.execute).to.be.an("function"); @@ -82,7 +88,15 @@ describe("reactRecoilFeature", function () { factory.add(def); const recoil: Partial = def._features.recoil; - const atomsMap = { key: "key", value: "RecoilState" }; + const atomsMap = { + initialState: { + state: { + todoListState: { key: "todoListState", value: [] }, + todoListFilterState: { key: "todoListFilterState", value: "Show All" } + }, + selectors: {} + } + }; render( recoil.wrap({ diff --git a/samples/subapp2-poc/src/app.tsx b/samples/subapp2-poc/src/app.tsx index 4f10206e5..520502173 100644 --- a/samples/subapp2-poc/src/app.tsx +++ b/samples/subapp2-poc/src/app.tsx @@ -12,7 +12,12 @@ export const staticHome = declareSubApp({ export const recoilApp = declareSubApp({ name: "recoilApp", - getModule: () => import("./recoilTodo") + getModule: () => import("./recoil-todo-app") +}); + +export const characterCounterApp = declareSubApp({ + name: "sampleApp", + getModule: () => import("./recoil-character-counter") }); xarcV2.debug("app.tsx"); diff --git a/samples/subapp2-poc/src/home.tsx b/samples/subapp2-poc/src/home.tsx index 10fb7501d..72cb2df0c 100644 --- a/samples/subapp2-poc/src/home.tsx +++ b/samples/subapp2-poc/src/home.tsx @@ -13,7 +13,6 @@ import { Demo2 } from "./demo2"; import { message } from "./message"; import electrodePng from "../static/electrode.png"; import custom from "./styles/custom.module.css"; // eslint-disable-line no-unused-vars -import { RecoilRoot } from "@xarc/react-recoil"; export const demo1 = declareSubApp({ name: "demo1", @@ -24,14 +23,9 @@ export const demo1B = declareSubApp({ name: "demo1b", getModule: () => import("./demo1") }); -export const recoilApp = declareSubApp({ - name: "recoilApp", - getModule: () => import("./recoilTodo") -}); const Demo1 = createDynamicComponent(demo1, { ssr: true }); const Demo1B = createDynamicComponent(demo1B, { ssr: true }); -const RecoilTodoApp = createDynamicComponent(recoilApp, { ssr: true }); export const Demo3 = subAppInlineComponent( declareSubApp({ @@ -88,7 +82,6 @@ const Home = props => {

subapp with react-query

-

Recoil Todo App

); }; diff --git a/samples/subapp2-poc/src/recoil-character-counter.tsx b/samples/subapp2-poc/src/recoil-character-counter.tsx new file mode 100644 index 000000000..5106834eb --- /dev/null +++ b/samples/subapp2-poc/src/recoil-character-counter.tsx @@ -0,0 +1,99 @@ +import { React, ReactSubApp, xarcV2, AppContext } from "@xarc/react"; + +import { recoilFeature, Recoil } from "@xarc/react-recoil"; + +const selectorsMap = new Map(); +const charCountState = store => { + if (selectorsMap.get("charCountState") === undefined) { + const selector = Recoil.selector({ + key: "charCountState", // unique ID (with respect to other atoms/selectors) + get: ({ get }) => { + const text = get(store.get("textState")); + return text.length; + } + }); + selectorsMap.set("charCountState", selector); + } + return function () { + return selectorsMap.get("charCountState"); + }; +}; + +function CharacterCount(props) { + const count = Recoil.useRecoilValue(charCountState(props.store)()); + + return <>Character Count: {count}; +} + +function CharacterCounter(props) { + return ( +
+ + +
+ ); +} + +function TextInput(props) { + const { store } = props; + const [text, setText] = Recoil.useRecoilState(store.get("textState")); + + const onChange = event => { + setText(event.target.value); + }; + + return ( +
+ +
+ Echo: {text} +
+ ); +} + +const CharacterCounterApp = props => { + return ( + + {({ isSsr, ssr }) => { + return props ? ( +
+

Recoil Character Counter App

+ +
+ ) : null; + }} +
+ ); +}; + +export { CharacterCounterApp as Component }; + +export const subapp: ReactSubApp = { + Component: CharacterCounterApp, + wantFeatures: [ + recoilFeature({ + React, + prepare: async initialState => { + xarcV2.debug("Recoil subapp recoil prepare, initialState:", initialState); + if (initialState) { + return { initialState }; + } else { + return { + initialState: { + state: { deal: { key: "textState", value: "My Special Recoil Deals......" } } + } + }; + } + } + }) + ] +}; diff --git a/samples/subapp2-poc/src/recoil-todo-app.tsx b/samples/subapp2-poc/src/recoil-todo-app.tsx new file mode 100644 index 000000000..4cfed4fbc --- /dev/null +++ b/samples/subapp2-poc/src/recoil-todo-app.tsx @@ -0,0 +1,259 @@ +import { React, ReactSubApp, xarcV2, AppContext } from "@xarc/react"; +import { recoilFeature, Recoil } from "@xarc/react-recoil"; + +const selectorsMap = new Map(); + +const filteredTodoListState = store => { + if (selectorsMap.get("filteredTodoListState") === undefined) { + const selector = Recoil.selector({ + key: "filteredTodoListState", + get: ({ get }) => { + const filter = get(store.get("todoListFilterState")); + const list = get(store.get("todoListState")); + + switch (filter) { + case "Show Completed": + return list.filter(item => item.isComplete); + case "Show Uncompleted": + return list.filter(item => !item.isComplete); + default: + return list; + } + } + }); + selectorsMap.set("filteredTodoListState", selector); + } + + return function () { + return selectorsMap.get("filteredTodoListState"); + }; +}; + +const todoListStatsState = store => { + if (selectorsMap.get("todoListStatsState") === undefined) { + const selector = Recoil.selector({ + key: "todoListStatsState", + get: ({ get }) => { + const todoList = get(store.get("todoListState")); + const totalNum = todoList.length; + const totalCompletedNum = todoList.filter(item => item.isComplete).length; + const totalUncompletedNum = totalNum - totalCompletedNum; + const percentCompleted = totalNum === 0 ? 0 : (totalCompletedNum / totalNum) * 100; + + return { + totalNum, + totalCompletedNum, + totalUncompletedNum, + percentCompleted + }; + } + }); + selectorsMap.set("todoListStatsState", selector); + } + return function () { + return selectorsMap.get("todoListStatsState"); + }; +}; + +function TodoItemCreator(props) { + const { store } = props; + const [inputValue, setInputValue] = React.useState(""); + const setTodoList = Recoil.useSetRecoilState(store.get("todoListState")); + + const addItem = () => { + setTodoList(oldTodoList => [ + ...oldTodoList, + { + id: getId(), + text: inputValue, + isComplete: false + } + ]); + setInputValue(""); + }; + + const onChange = ({ target: { value } }) => { + setInputValue(value); + }; + + return ( +
+ + +
+ ); +} + +// utility for creating unique Id +let id = 0; +function getId() { + return id++; +} + +function TodoItem(props) { + const { item, store } = props; + const [todoList, setTodoList] = Recoil.useRecoilState(store.get("todoListState")); + const index = todoList.findIndex(listItem => listItem === item); + + const editItemText = ({ target: { value } }) => { + const newList = replaceItemAtIndex(todoList, index, { + ...item, + text: value + }); + + setTodoList(newList); + }; + + const toggleItemCompletion = () => { + const newList = replaceItemAtIndex(todoList, index, { + ...item, + isComplete: !item.isComplete + }); + + setTodoList(newList); + }; + + const deleteItem = () => { + const newList = removeItemAtIndex(todoList, index); + + setTodoList(newList); + }; + + return ( +
+ + + +
+ ); +} + +function replaceItemAtIndex(arr, index, newValue) { + return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)]; +} + +function removeItemAtIndex(arr, index) { + return [...arr.slice(0, index), ...arr.slice(index + 1)]; +} + +function TodoListFilters(props) { + const { store } = props; + const [filter, setFilter] = Recoil.useRecoilState(store.get("todoListFilterState")); + + const updateFilter = ({ target: { value } }) => { + setFilter(value); + }; + + return ( + <> + Filter: + + + ); +} + +function TodoListStats(props) { + const { store } = props; + const { + totalNum, + totalCompletedNum, + totalUncompletedNum, + percentCompleted + } = Recoil.useRecoilValue(todoListStatsState(store)()); + + const formattedPercentCompleted = Math.round(percentCompleted); + + return ( +
    +
  • Total items: {totalNum}
  • +
  • Items completed: {totalCompletedNum}
  • +
  • Items not completed: {totalUncompletedNum}
  • +
  • Percent completed: {formattedPercentCompleted}
  • +
+ ); +} + +function TodoItems(props) { + const { store } = props; + // changed from todoListState to filteredTodoListState + const todoList = Recoil.useRecoilValue(filteredTodoListState(store)()); + return ( +
+ {todoList.map(todoItem => ( + + ))} +
+ ); +} + +function TodoList(props) { + console.log(`checking the render `); + return ( +
+ + + + +
+ ); +} + +const TodoListApp = (props: any) => { + return ; +}; + +const RecoilTodoApp = props => { + const { store } = props; + return ( + + {({ isSsr, ssr }) => { + return ( +
+

Recoil Todo App

+ +
+ ); + }} +
+ ); +}; + +export { RecoilTodoApp as Component }; + +export const subapp: ReactSubApp = { + Component: RecoilTodoApp, + wantFeatures: [ + recoilFeature({ + React, + prepare: async initialState => { + xarcV2.debug("Recoil subapp recoil prepare, initialState:", initialState); + if (initialState) { + return { initialState }; + } + return { + // create two sections now - one for the state and another for selectors and should not be created twice because of the duplicate issue + initialState: { + state: { + todoListState: { key: "todoListState", value: [] }, + todoListFilterState: { key: "todoListFilterState", value: "Show All" } + }, + selectors: {} + } + }; + } + }) + ] +}; diff --git a/samples/subapp2-poc/src/recoilTodo.tsx b/samples/subapp2-poc/src/recoilTodo.tsx deleted file mode 100644 index 9f0d42244..000000000 --- a/samples/subapp2-poc/src/recoilTodo.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { React, ReactSubApp, xarcV2, AppContext } from "@xarc/react"; -import { - recoilFeature, - useRecoilState, - useRecoilValue, - useSetRecoilState, - selector, - atom -} from "@xarc/react-recoil"; - -const todoListState = atom({ - key: "todoListState", - default: [] -}); - -const todoListFilterState = atom({ - key: "todoListFilterState", - default: "Show All" -}); - -const filteredTodoListState = selector({ - key: "filteredTodoListState", - get: ({ get }) => { - const filter = get(todoListFilterState); - const list = get(todoListState); - - switch (filter) { - case "Show Completed": - return list.filter(item => item.isComplete); - case "Show Uncompleted": - return list.filter(item => !item.isComplete); - default: - return list; - } - } -}); - -const todoListStatsState = selector({ - key: "todoListStatsState", - get: ({ get }) => { - const todoList = get(todoListState); - const totalNum = todoList.length; - const totalCompletedNum = todoList.filter(item => item.isComplete).length; - const totalUncompletedNum = totalNum - totalCompletedNum; - const percentCompleted = totalNum === 0 ? 0 : (totalCompletedNum / totalNum) * 100; - - return { - totalNum, - totalCompletedNum, - totalUncompletedNum, - percentCompleted - }; - } -}); - -function TodoItemCreator() { - const [inputValue, setInputValue] = React.useState(""); - const setTodoList = useSetRecoilState(todoListState); - - const addItem = () => { - setTodoList(oldTodoList => [ - ...oldTodoList, - { - id: getId(), - text: inputValue, - isComplete: false - } - ]); - setInputValue(""); - }; - - const onChange = ({ target: { value } }) => { - setInputValue(value); - }; - - return ( -
- - -
- ); -} - -// utility for creating unique Id -let id = 0; -function getId() { - return id++; -} - -function TodoItem({ item }) { - const [todoList, setTodoList] = useRecoilState(todoListState); - const index = todoList.findIndex(listItem => listItem === item); - - const editItemText = ({ target: { value } }) => { - const newList = replaceItemAtIndex(todoList, index, { - ...item, - text: value - }); - - setTodoList(newList); - }; - - const toggleItemCompletion = () => { - const newList = replaceItemAtIndex(todoList, index, { - ...item, - isComplete: !item.isComplete - }); - - setTodoList(newList); - }; - - const deleteItem = () => { - const newList = removeItemAtIndex(todoList, index); - - setTodoList(newList); - }; - - return ( -
- - - -
- ); -} - -function replaceItemAtIndex(arr, index, newValue) { - return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)]; -} - -function removeItemAtIndex(arr, index) { - return [...arr.slice(0, index), ...arr.slice(index + 1)]; -} - -function TodoListFilters() { - const [filter, setFilter] = useRecoilState(todoListFilterState); - - const updateFilter = ({ target: { value } }) => { - setFilter(value); - }; - - return ( - <> - Filter: - - - ); -} - -function TodoListStats() { - const { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted } = useRecoilValue( - todoListStatsState - ); - - const formattedPercentCompleted = Math.round(percentCompleted); - - return ( -
    -
  • Total items: {totalNum}
  • -
  • Items completed: {totalCompletedNum}
  • -
  • Items not completed: {totalUncompletedNum}
  • -
  • Percent completed: {formattedPercentCompleted}
  • -
- ); -} - -function TodoList(props) { - // changed from todoListState to filteredTodoListState - const todoList = useRecoilValue(filteredTodoListState); - - return ( - <> - - - - - {todoList.map(todoItem => ( - - ))} - - ); -} - -const TodoListApp = (props: any) => (WrappedComponent: any) => (moreProps: any) => { - return ; -}; - -const RecoilTodoApp = props => { - const { store } = props; - console.log(store); - return ( - - {({ isSsr, ssr }) => { - return ( -
- -
- ); - }} -
- ); -}; - -export { RecoilTodoApp as Component }; - -export const subapp: ReactSubApp = { - Component: RecoilTodoApp, - wantFeatures: [ - recoilFeature({ - React, - prepare: async initialState => { - if (initialState) { - return initialState; - } - return { - atoms: [ - { key: "todoListState", value: todoListState }, - { key: "todoListFilterState", value: todoListFilterState } - ] - }; - } - }) - ] -}; diff --git a/samples/subapp2-poc/src/server/routes.tsx b/samples/subapp2-poc/src/server/routes.tsx index 0c7f6e448..35b4cfe1d 100644 --- a/samples/subapp2-poc/src/server/routes.tsx +++ b/samples/subapp2-poc/src/server/routes.tsx @@ -14,7 +14,7 @@ import { PageOptions } from "@xarc/react"; -import { home, staticHome, recoilApp } from "../app"; +import { home, staticHome, characterCounterApp, recoilApp } from "../app"; import { renderToString } from "react-dom/server"; const MEMOIZE_STORE = {}; @@ -98,7 +98,8 @@ ${s} namespace: "poc1", subApps: [ { name: home.name, ssr: true }, - { name: recoilApp.name, ssr: true }, + { name: recoilApp.name, ssr: true }, //sampleApp + { name: characterCounterApp.name, ssr: true }, //sampleApp Demo3.loadOptions, { name: "demo4", ssr: true, prepareOnly: true, inlineId: "1234" } ], @@ -187,6 +188,7 @@ ${s} { name: staticHome.name, ssr: true }, { name: "demo3", ssr: true, prepareOnly: true, inlineId: "123" }, { name: home.name, ssr: true }, + { name: recoilApp.name, ssr: true } ], ...commonRenderOptions