diff --git a/Directory.Build.props b/Directory.Build.props index e73a76e6..90dcc8e0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ 2.0.0.0 2.0.0.0 - 5.120.1 + 5.123.0 OutSystems ReactView Copyright © OutSystems 2023 @@ -14,6 +14,9 @@ .npm-install-stamp + + + true diff --git a/ReactViewResources/AMDLoader/AMDLoader.ts b/ReactViewResources/AMDLoader/AMDLoader.ts index ffc074d9..ffffd8b3 100644 --- a/ReactViewResources/AMDLoader/AMDLoader.ts +++ b/ReactViewResources/AMDLoader/AMDLoader.ts @@ -11,6 +11,7 @@ namespace AMDLoader { export let timeout = 5000; export function getOrCreateDependencyPromise(name: string): Promise { + console.log("AMDLoader :: getOrCreateDependencyPromise: ", name); name = name.replace(/^.\//, "").toLowerCase(); if (!promises[name]) { promises[name] = new Promise((resolve, reject) => { @@ -29,12 +30,14 @@ namespace AMDLoader { } export function resolve(name: string, value: any): void { + console.log("AMDLoader :: resolve: ", name);1 getOrCreateDependencyPromise(name); // create promise if necessary resolves[name](value); defines[name] = true; } export function require(deps: string[], definition: Function): void { + console.log("AMDLoader :: require: ", deps); if (!deps || deps.length === 0) { definition.apply(null, []); return; @@ -49,6 +52,7 @@ namespace AMDLoader { } const define = function (name: string, deps: string[], definition: Function): void { + console.log("AMDLoader :: define: ", name); if (typeof name !== "string") { throw new Error("Unnamed modules are not supported"); } diff --git a/ReactViewResources/Loader/Internal/ComponentsRenderCache.ts b/ReactViewResources/Loader/Internal/ComponentsRenderCache.ts index 19dc2c35..516e349f 100644 --- a/ReactViewResources/Loader/Internal/ComponentsRenderCache.ts +++ b/ReactViewResources/Loader/Internal/ComponentsRenderCache.ts @@ -7,27 +7,29 @@ export interface IRenderCacheEntry { } export async function renderCachedView(view: ViewMetadata, componentSource: string, componentPropertiesHash: string): Promise { - if (!view.isMain) { - // disable render from cache for inner views, since react does not currently support portals hydration - return null; - } - - const componentCacheKey = componentSource + "|" + componentPropertiesHash; - - const cachedComponentHtml = localStorage.getItem(componentCacheKey); - if (cachedComponentHtml) { - // render cached component html to reduce time to first render - view.root!.innerHTML = cachedComponentHtml; - await waitForNextPaint(); - - // already on cache, skip storing on cache - return null; - } - - return { - cacheKey: componentCacheKey, - componentSource: componentSource - }; + return null; + // + // if (!view.isMain) { + // // disable render from cache for inner views, since react does not currently support portals hydration + // return null; + // } + // + // const componentCacheKey = componentSource + "|" + componentPropertiesHash; + // + // const cachedComponentHtml = localStorage.getItem(componentCacheKey); + // if (cachedComponentHtml) { + // // render cached component html to reduce time to first render + // view.root!.innerHTML = cachedComponentHtml; + // await waitForNextPaint(); + // + // // already on cache, skip storing on cache + // return null; + // } + // + // return { + // cacheKey: componentCacheKey, + // componentSource: componentSource + // }; } export function storeViewRenderInCache(view: ViewMetadata, cacheEntry: IRenderCacheEntry, maxPreRenderedCacheEntries: number): Promise { diff --git a/ReactViewResources/Loader/Internal/Loader.View.tsx b/ReactViewResources/Loader/Internal/Loader.View.tsx index 0c7f425e..e944358f 100644 --- a/ReactViewResources/Loader/Internal/Loader.View.tsx +++ b/ReactViewResources/Loader/Internal/Loader.View.tsx @@ -9,19 +9,36 @@ import { ViewMetadata } from "./ViewMetadata"; import { ViewPortalsCollection } from "./ViewPortalsCollection"; import { addView, deleteView } from "./ViewsCollection"; -export function createView(componentClass: any, properties: {}, view: ViewMetadata, componentName: string) { - componentClass.contextType = PluginsContext; +export function createView(componentClass: any, properties: {}, view: ViewMetadata, componentName: string, componentNativeObject: any, componentNativeObjectName: string) { + return ; +} +interface IViewProps { + componentClass: any; + properties: {}; + view: ViewMetadata; + componentName: string + componentNativeObject: any; + componentNativeObjectName: string +} + +const View = ({ componentClass, properties, view, componentName, componentNativeObject, componentNativeObjectName }: IViewProps) => { + componentClass.contextType = PluginsContext; const makeResourceUrl = (resourceKey: string, ...params: string[]) => formatUrl(view.name, resourceKey, ...params); + const pluginsContext = React.useRef(new PluginsContextHolder(Array.from(view.modules.values()))); + + React.useEffect(() => { + return () => { + pluginsContext.current.dispose(); + pluginsContext.current = null!; + } + }, []); + return ( - + - {React.createElement(componentClass, { ref: e => view.modules.set(componentName, e), ...properties })} diff --git a/ReactViewResources/Loader/Internal/NativeAPI.ts b/ReactViewResources/Loader/Internal/NativeAPI.ts index 2d0c8d5a..8a390087 100644 --- a/ReactViewResources/Loader/Internal/NativeAPI.ts +++ b/ReactViewResources/Loader/Internal/NativeAPI.ts @@ -33,5 +33,7 @@ export function notifyViewLoaded(viewName: string, id: string): void { } export function notifyViewDestroyed(viewName: string): void { - withAPI(api => api.notifyViewDestroyed(viewName)); -} \ No newline at end of file + withAPI(api => { + api.notifyViewDestroyed(viewName) + }); +} diff --git a/ReactViewResources/Loader/Internal/ResourcesLoader.ts b/ReactViewResources/Loader/Internal/ResourcesLoader.ts index a97e4de1..59e89bc7 100644 --- a/ReactViewResources/Loader/Internal/ResourcesLoader.ts +++ b/ReactViewResources/Loader/Internal/ResourcesLoader.ts @@ -4,21 +4,47 @@ import { Task } from "./Task"; import { ViewMetadata } from "./ViewMetadata"; import { getView } from "./ViewsCollection"; +console.log("Loading resources loader file..."); +const LoadedScriptsKey = "LOADED_SCRIPTS_KEY"; +const ScriptLoadTasksKey = "SCRIPT_LOAD_TASKS_KEY"; +let loadedScripts: Set = window[LoadedScriptsKey]; +let scriptLoadTasks: Map> = window[ScriptLoadTasksKey]; + +if (!loadedScripts) { + console.log("Create new 'loadedScripts' SET !!"); + loadedScripts = new Set(); + window[LoadedScriptsKey] = loadedScripts; +} + +if (!scriptLoadTasks) { + console.log("Create new 'scriptLoadTasks' MAP !!"); + scriptLoadTasks = new Map>(); + window[LoadedScriptsKey] = loadedScripts; +} + export function loadScript(scriptSrc: string, view: ViewMetadata): Promise { return new Promise(async (resolve) => { - const frameScripts = view.scriptsLoadTasks; - // check if script was already added, fallback to main frame - const scriptLoadTask = frameScripts.get(scriptSrc) || !view.isMain ? getView(mainFrameName).scriptsLoadTasks.get(scriptSrc) : null; + const scriptLoadTask = scriptLoadTasks.get(scriptSrc) || null; if (scriptLoadTask) { + console.log("Wait loading TASK for: " + scriptSrc); // wait for script to be loaded await scriptLoadTask.promise; + scriptLoadTasks.delete(scriptSrc); + resolve(); + return; + } + + if(loadedScripts.has(scriptSrc)) { + console.log("Load skipped for: " + scriptSrc); resolve(); return; } + console.log("Load script Task: " + scriptSrc); + loadedScripts.add(scriptSrc); const loadTask = new Task(); - frameScripts.set(scriptSrc, loadTask); + scriptLoadTasks.set(scriptSrc, loadTask); const script = document.createElement("script"); script.src = scriptSrc; @@ -54,18 +80,51 @@ export function loadStyleSheet(stylesheet: string, containerElement: Element, ma } function waitForLoad(element: T, url: string, timeout: number): Promise { - return new Promise((resolve) => { - const timeoutHandle = setTimeout( - () => { - if (isDebugModeEnabled) { - showWarningMessage(`Timeout loading resouce: '${url}'. If you paused the application to debug, you may disregard this message.`); - } - }, - timeout); - - element.addEventListener("load", () => { - clearTimeout(timeoutHandle); + return new Promise((resolve, reject) => { + // const timeoutHandle = setTimeout( + // () => { + // if (isDebugModeEnabled) { + // showWarningMessage(`Timeout loading resouce: '${url}'. If you paused the application to debug, you may disregard this message.`); + // } + // }, + // timeout); + + // element.addEventListener("load", () => { + // clearTimeout(timeoutHandle); + // resolve(element); + // }); + + let timeoutHandle: number | undefined; + + // Define handlers ahead of time + const loadHandler = () => { + cleanup(); resolve(element); - }); + }; + + const errorHandler = () => { + cleanup(); + reject(new Error(`Failed to load resource: '${url}'`)); + }; + + // This central function is key to disposing of everything + const cleanup = () => { + clearTimeout(timeoutHandle); + element.removeEventListener("load", loadHandler); + element.removeEventListener("error", errorHandler); + }; + + // Set the timeout to reject the promise and clean up + timeoutHandle = setTimeout(() => { + cleanup(); + const message = `Timeout loading resource: '${url}'.`; + if (isDebugModeEnabled) { + showWarningMessage(`${message} If you paused the application to debug, you may disregard this message.`); + } + reject(new Error(message)); + }, timeout); + + element.addEventListener("load", loadHandler); + element.addEventListener("error", errorHandler); }); } \ No newline at end of file diff --git a/ReactViewResources/Loader/Internal/ViewMetadata.ts b/ReactViewResources/Loader/Internal/ViewMetadata.ts index 53426d56..68c37596 100644 --- a/ReactViewResources/Loader/Internal/ViewMetadata.ts +++ b/ReactViewResources/Loader/Internal/ViewMetadata.ts @@ -9,7 +9,6 @@ export type ViewMetadata = { placeholder: Element; // element were the view is mounted (where the shadow root is mounted in case of child views) root?: Element; // view root element head?: Element; // view head element - scriptsLoadTasks: Map>; // maps scripts urls to load tasks pluginsLoadTask: Task; // plugins load task viewLoadTask: Task; // resolved when view is loaded modules: Map; // maps module name to module instance @@ -33,7 +32,6 @@ export function newView(id: number, name: string, isMain: boolean, placeholder: nativeObjectNames: [], pluginsLoadTask: new Task(), viewLoadTask: new Task(), - scriptsLoadTasks: new Map>(), childViews: new ObservableListCollection(), context: null, parentView: null! diff --git a/ReactViewResources/Loader/Internal/ViewPortal.tsx b/ReactViewResources/Loader/Internal/ViewPortal.tsx index 239e7728..e8841e6d 100644 --- a/ReactViewResources/Loader/Internal/ViewPortal.tsx +++ b/ReactViewResources/Loader/Internal/ViewPortal.tsx @@ -28,12 +28,13 @@ interface IViewPortalState { * */ export class ViewPortal extends React.Component { - private head: Element; - private shadowRoot: HTMLElement; + private head: Element | null = null; + private shadowRoot: HTMLElement | null = null; constructor(props: IViewPortalProps, context: any) { super(props, context); + debugger; this.state = { component: null! }; this.shadowRoot = props.view.placeholder.attachShadow({ mode: "open" }).getRootNode() as HTMLElement; @@ -42,6 +43,7 @@ export class ViewPortal extends React.Component {component} @@ -56,21 +58,24 @@ export class ViewPortal extends React.Component s.dataset.sticky === "true"); - stylesheets.forEach(s => this.head.appendChild(document.importNode(s, true))); + stylesheets.forEach(s => this.head!.appendChild(document.importNode(s, true))); this.props.viewMounted(this.props.view); } public componentWillUnmount() { + this.head = null; + this.shadowRoot = null; this.props.viewUnmounted(this.props.view); } @@ -90,6 +95,6 @@ export class ViewPortal extends React.Component , - this.shadowRoot); + this.shadowRoot!); } } \ No newline at end of file diff --git a/ReactViewResources/Loader/Internal/ViewPropertiesProxy.ts b/ReactViewResources/Loader/Internal/ViewPropertiesProxy.ts index e5a60ae7..8ba295bb 100644 --- a/ReactViewResources/Loader/Internal/ViewPropertiesProxy.ts +++ b/ReactViewResources/Loader/Internal/ViewPropertiesProxy.ts @@ -10,7 +10,6 @@ export function createPropertiesProxy(rootElement: Element, objProperties: {}, n } else { proxy[key] = async function () { const nativeObject = window[nativeObjName] || await bindNativeObject(nativeObjName); - const result = nativeObject[key].apply(window, arguments); if (componentRenderedWaitTask) { diff --git a/ReactViewResources/Loader/Internal/ViewsCollection.ts b/ReactViewResources/Loader/Internal/ViewsCollection.ts index e54f97b0..32719013 100644 --- a/ReactViewResources/Loader/Internal/ViewsCollection.ts +++ b/ReactViewResources/Loader/Internal/ViewsCollection.ts @@ -3,6 +3,8 @@ import { modulesFunctionName } from "./Environment"; const views = new Map(); +window["my-views"] = views; // for debugging + export function addView(name: string, view: ViewMetadata): void { views.set(name, view); } @@ -23,6 +25,7 @@ export function getView(viewName: string): ViewMetadata { return view; } +console.log("modulesFunctionName", modulesFunctionName); window[modulesFunctionName] = function getModule(viewName: string, id: string, moduleName: string) { const view = views.get(viewName); if (view && view.id.toString() === id) { @@ -35,7 +38,7 @@ window[modulesFunctionName] = function getModule(viewName: string, id: string, m return new Proxy({}, { get: function () { - // return a dummy function, call will be ingored, but no exception thrown + // return a dummy function, call will be ignored, but no exception thrown return new Function(); } }); diff --git a/ReactViewResources/Loader/Loader.ts b/ReactViewResources/Loader/Loader.ts index 0e4ff86d..da52813b 100644 --- a/ReactViewResources/Loader/Loader.ts +++ b/ReactViewResources/Loader/Loader.ts @@ -115,6 +115,8 @@ export function loadPlugins(plugins: any[][], frameName: string): void { innerLoad(); } +let createView; + export function loadComponent( componentName: string, componentNativeObjectName: string, @@ -135,7 +137,7 @@ export function loadComponent( // wait for the stylesheet to load before first render await defaultStylesheetLoadTask.promise; } - + view = tryGetView(frameName)!; if (!view) { return; // view was probably unloaded @@ -174,20 +176,23 @@ export function loadComponent( throw new Error(`Component ${componentName} is not defined or does not have a default class`); } - const { createView } = await import("./Internal/Loader.View"); + if (!createView) { + console.log("LOAD 'Internal/Loader.View' !!") + const loader = await import("./Internal/Loader.View"); + createView = loader.createView; + } - const viewElement = createView(componentClass, properties, view, componentName); + const viewElement = createView(componentClass, properties, view, componentName, componentNativeObject, componentNativeObjectName); const render = view.renderHandler; if (!render) { throw new Error(`View ${view.name} render handler is not set`); } - + await render(viewElement); - await waitForNextPaint(); if (cacheEntry) { - storeViewRenderInCache(view, cacheEntry, maxPreRenderedCacheEntries); // dont need to await + //storeViewRenderInCache(view, cacheEntry, maxPreRenderedCacheEntries); // dont need to await } // queue view loaded notification to run after all other pending promise notifications (ensure order) diff --git a/ReactViewResources/Loader/Public/PluginsContext.ts b/ReactViewResources/Loader/Public/PluginsContext.ts index bac77f2c..4a89e684 100644 --- a/ReactViewResources/Loader/Public/PluginsContext.ts +++ b/ReactViewResources/Loader/Public/PluginsContext.ts @@ -14,6 +14,11 @@ export class PluginsContextHolder implements IPluginsContext { public getPluginInstance(_class: Type) { return this.pluginInstances.get(_class.name); } + + public dispose(): void { + this.pluginInstances.clear(); + this.pluginInstances = null!; + } } export const PluginsContext = React.createContext(null!); diff --git a/ReactViewResources/Loader/Public/ViewFrame.tsx b/ReactViewResources/Loader/Public/ViewFrame.tsx index a2ed29be..ab111eaa 100644 --- a/ReactViewResources/Loader/Public/ViewFrame.tsx +++ b/ReactViewResources/Loader/Public/ViewFrame.tsx @@ -1,29 +1,59 @@ -import * as React from "react"; -import { IViewFrameProps } from "ViewFrame"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; +// import { IViewFrameProps } from "ViewFrame"; import { newView, ViewMetadata } from "../Internal/ViewMetadata"; import { ViewMetadataContext } from "../Internal/ViewMetadataContext"; import { ViewSharedContext } from "./ViewSharedContext"; +import { getStylesheets } from "../Internal/Common"; +import {addView, deleteView} from "../Internal/ViewsCollection"; +import {notifyViewDestroyed, notifyViewInitialized} from "../Internal/NativeAPI"; +import {handleError} from "../Internal/ErrorHandler"; +import {webViewRootId} from "../Internal/Environment"; + +function onChildViewAdded(childView: ViewMetadata) { + addView(childView.name, childView); + notifyViewInitialized(childView.name); +} + +function onChildViewRemoved(childView: ViewMetadata) { + deleteView(childView.name); + notifyViewDestroyed(childView.name); +} + +function onChildViewErrorRaised(childView: ViewMetadata, error: Error) { + handleError(error, childView); +} + +// --- INTERFACES --- + +// Update the props interface to formally include a 'loaded' callback +// that provides the ViewMetadata, which will contain our render handler. +export interface IViewFrameProps { + name: string | number; + className?: string; + context?: T; + loaded?: () => void; +} interface IInternalViewFrameProps extends IViewFrameProps { viewMetadata: ViewMetadata; context: any; } -/** - * Placeholder were a child view is mounted. - * */ -export class ViewFrame extends React.Component, {}, ViewMetadata> { +interface IInternalViewFrameState { + // This will hold the component passed to the renderHandler + componentToRender: React.ReactElement | null; +} - constructor(props: IViewFrameProps, context: any) { - super(props, context); - } +// --- PUBLIC COMPONENT (Unchanged) --- +export class ViewFrame extends React.Component, {}> { public render(): JSX.Element { return ( {viewMetadata => - {viewcontext => } + {viewContext => } } @@ -31,97 +61,264 @@ export class ViewFrame extends React.Component, {}, ViewMe } } -class InternalViewFrame extends React.Component, {}, ViewMetadata> { +// --- INTERNAL COMPONENT (Refactored for renderHandler) --- +class InternalViewFrame extends React.Component, IInternalViewFrameState> { private static generation = 0; - - private generation: number; - private placeholder: Element; - private replacement: Element; - + private readonly generation: number; + + private placeholder: HTMLDivElement | null = null; + private replacement: Element | null = null; + + private shadowRoot: Element | null = null; + private head: HTMLElement | null; + private root: HTMLElement | null; + constructor(props: IInternalViewFrameProps, context: any) { super(props, context); - if (props.name === "") { - throw new Error("View Frame name must be specified (not empty)"); - } - if (!/^[A-Za-z_][A-Za-z0-9_]*/.test(props.name as string)) { - // must be a valid js symbol name - throw new Error("View Frame name can only contain letters, numbers or _"); - } + this.state = { + componentToRender: null, // Initialize component as null + }; - // keep track of this frame generation, so that we can keep tracking the most recent frame instance - this.generation = ++InternalViewFrame.generation; + if (props.name === "") throw new Error("View Frame name must be specified (not empty)"); + if (!/^[A-Za-z_][A-Za-z0-9_]*/.test(props.name as string)) throw new Error("View Frame name can only contain letters, numbers or _"); - const view = this.getView(); - if (view) { - // update the existing view generation - view.generation = this.generation; - } + this.generation = ++InternalViewFrame.generation; } - private get fullName() { - const parentName = this.parentView.name; + // ... (getters like fullName, parentView, getView remain the same) ... + private get fullName(): string { + const parentName = this.props.viewMetadata.name; // @ts-ignore return (parentName ? (parentName + ".") : "") + this.props.name; } - public shouldComponentUpdate(): boolean { - // prevent component updates - return false; - } - private get parentView(): ViewMetadata { return this.props.viewMetadata; } private getView(): ViewMetadata | undefined { - const fullName = this.fullName; - return this.parentView.childViews.items.find(c => c.name === fullName); + return this.parentView.childViews.items.find(c => c.name === this.fullName); } + /** + * This method will be exposed as the renderHandler. It sets the component + * in state, which causes the portal to render it. + */ + private renderInPortal = (component: React.ReactElement): Promise => { + const view = this.getView(); + if (!view) { + return Promise.reject(new Error("ViewFrame not mounted or already destroyed.")); + } + + const wrappedComponent = ( + + {component} + + ); + + return new Promise(resolve => { + this.setState({ componentToRender: wrappedComponent }, resolve); + }); + }; + public componentDidMount() { + // if (!this.placeholder || !this.root || !this.head || !this.shadowRoot) { + // // Should never happen. consider removing + // return; + // } + // + // const existingView = this.getView(); + // if (existingView) { + // this.replacement = existingView.placeholder; + // this.placeholder.parentElement!.replaceChild(this.replacement, this.placeholder); + // return; + // } + // + // const id = this.generation; + // const childView = newView(id, this.fullName, false, this.placeholder); + // childView.generation = this.generation; + // childView.parentView = this.parentView; + // childView.context = this.props.context; + // + // debugger + // + // // Notify the parent that the frame is ready by calling the 'loaded' prop + // const loadedHandler = this.props.loaded; + // if (loadedHandler) { + // // The handler receives the view object, which now contains our renderHandler + // childView.viewLoadTask.promise.then(() => loadedHandler()); + // } + // + // // view portal + // // **KEY CHANGE**: Attach our render method to the metadata object + // + // + // // const shadowRoot = this.placeholder.attachShadow({ mode: "open" }).getRootNode() as HTMLElement; + // // const head = document.createElement("head"); + // // const body = document.createElement("body"); + // // const portalRootDiv = document.createElement("div"); + // // portalRootDiv.id = webViewRootId; + // + // const styleResets = document.createElement("style"); + // styleResets.innerHTML = ":host { all: initial; display: block; }"; + // this.head.appendChild(styleResets); + // + // // get sticky stylesheets + // const stylesheets = getStylesheets(document.head).filter(s => s.dataset.sticky === "true"); + // stylesheets.forEach(s => this.head?.appendChild(document.importNode(s, true))); + // + // // body.appendChild(portalRootDiv); + // // shadowRoot.appendChild(head); + // // shadowRoot.appendChild(body); + // + // childView.head = this.head; + // childView.root = this.root; + // + // this.parentView.childViews.add(childView); + // + // childView.renderHandler = component => this.renderInPortal(component); + // // Set the mount point to enable the portal, but render no content yet. + // // this.setState({ portalMountPoint: portalRootDiv }); + // onChildViewAdded(childView); + } + + public componentWillUnmount() { + + if (this.replacement) { + this.replacement.parentElement!.replaceChild(this.placeholder!, this.replacement); + } + + const existingView = this.getView(); + if (existingView && this.generation === existingView.generation) { + this.parentView.childViews.remove(existingView); + onChildViewRemoved(existingView); + console.log("unmount internal view frame", this.fullName); + } + + // if (this.placeholder && this.shadowRoot) { + // this.placeholder?.removeChild(this.shadowRoot) + // } + + this.setState({ componentToRender: null }); + + this.placeholder = null; + this.replacement = null; + this.shadowRoot = null; + this.head = null; + this.root = null; + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + const existingView = this.getView(); + if (!existingView) { + // View is not available, log error generically + return; + } + // execute error handling inside promise, to avoid the error handler to rethrow exception inside componentDidCatch + Promise.resolve(null).then(() => onChildViewErrorRaised(existingView, error)); + } + + private setContainer = (element: HTMLDivElement) => { + this.placeholder = element; + if (this.placeholder && !this.shadowRoot) { + // create an open shadow-dom, so that bubbled events expose the inner element + this.shadowRoot = this.placeholder.attachShadow({ mode: "open" }).getRootNode() as Element; + this.forceUpdate(); + } + } + + private setRoot = (element: HTMLDivElement) => { + this.root = element; + + if (!this.placeholder || !this.root || !this.head || !this.shadowRoot) { + return; + } + const existingView = this.getView(); if (existingView) { - // there's a view already rendered, insert in current frame's placeholder this.replacement = existingView.placeholder; this.placeholder.parentElement!.replaceChild(this.replacement, this.placeholder); return; } - const id = this.generation; // for this purpose we can use generation (we just need a unique number) + const id = this.generation; const childView = newView(id, this.fullName, false, this.placeholder); childView.generation = this.generation; childView.parentView = this.parentView; childView.context = this.props.context; + // Notify the parent that the frame is ready by calling the 'loaded' prop const loadedHandler = this.props.loaded; if (loadedHandler) { + // The handler receives the view object, which now contains our renderHandler childView.viewLoadTask.promise.then(() => loadedHandler()); } - this.parentView.childViews.add(childView); - } + // view portal + // **KEY CHANGE**: Attach our render method to the metadata object - public componentWillUnmount() { - if (this.replacement) { - // put back the original container, otherwise react will complain - this.replacement.parentElement!.replaceChild(this.placeholder, this.replacement); - } - const existingView = this.getView(); - if (existingView && this.generation === existingView.generation) { - // this is the most recent frame - meaning it was not replaced by another one - so the view should be removed - this.parentView.childViews.remove(existingView); + // const shadowRoot = this.placeholder.attachShadow({ mode: "open" }).getRootNode() as HTMLElement; + // const head = document.createElement("head"); + // const body = document.createElement("body"); + // const portalRootDiv = document.createElement("div"); + // portalRootDiv.id = webViewRootId; + + const styleResets = document.createElement("style"); + styleResets.innerHTML = ":host { all: initial; display: block; }"; + this.head.appendChild(styleResets); + + // get sticky stylesheets + const stylesheets = getStylesheets(document.head).filter(s => s.dataset.sticky === "true"); + stylesheets.forEach(s => this.head?.appendChild(document.importNode(s, true))); + + // body.appendChild(portalRootDiv); + // shadowRoot.appendChild(head); + // shadowRoot.appendChild(body); + + childView.head = this.head; + childView.root = this.root; + + this.parentView.childViews.add(childView); + + childView.renderHandler = component => this.renderInPortal(component); + // Set the mount point to enable the portal, but render no content yet. + // this.setState({ portalMountPoint: portalRootDiv }); + onChildViewAdded(childView); + } + + private renderPortal() { + if (!this.shadowRoot) { + return null; } + + return ReactDOM.createPortal( + <> + this.head = e!}> + + +
+ {this.state.componentToRender ? this.state.componentToRender : null} +
+ + , + this.shadowRoot); } - + public render() { - return
this.placeholder = e!} className={this.props.className} />; + return ( +
+ {/* The portal now renders the component from the state */} + {/*{portalMountPoint && componentToRender && ReactDOM.createPortal(componentToRender, portalMountPoint)}*/} + {this.renderPortal()} +
+ ); } } window["ViewFrame"] = { ViewFrame: ViewFrame, ViewSharedContext: ViewSharedContext -}; +}; \ No newline at end of file diff --git a/ReactViewResources/Loader/Public/ViewFrameLegacy.tsx b/ReactViewResources/Loader/Public/ViewFrameLegacy.tsx new file mode 100644 index 00000000..bfeb836f --- /dev/null +++ b/ReactViewResources/Loader/Public/ViewFrameLegacy.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; +import { IViewFrameProps } from "ViewFrame"; +import { newView, ViewMetadata } from "../Internal/ViewMetadata"; +import { ViewMetadataContext } from "../Internal/ViewMetadataContext"; +import { ViewSharedContext } from "./ViewSharedContext"; + +interface IInternalViewFrameProps extends IViewFrameProps { + viewMetadata: ViewMetadata; + context: any; +} + +/** + * Placeholder were a child view is mounted. + * */ +export class ViewFrameLegacy extends React.Component, {}, ViewMetadata> { + + constructor(props: IViewFrameProps, context: any) { + super(props, context); + } + + public render(): JSX.Element { + return ( + + {viewMetadata => + + {viewcontext => } + + } + + ); + } +} + +class InternalViewFrame extends React.Component, {}, ViewMetadata> { + + private static generation = 0; + + private generation: number; + private placeholder: Element; + private replacement: Element; + + constructor(props: IInternalViewFrameProps, context: any) { + super(props, context); + if (props.name === "") { + throw new Error("View Frame name must be specified (not empty)"); + } + + if (!/^[A-Za-z_][A-Za-z0-9_]*/.test(props.name as string)) { + // must be a valid js symbol name + throw new Error("View Frame name can only contain letters, numbers or _"); + } + + // keep track of this frame generation, so that we can keep tracking the most recent frame instance + this.generation = ++InternalViewFrame.generation; + + const view = this.getView(); + if (view) { + // update the existing view generation + view.generation = this.generation; + } + } + + private get fullName() { + const parentName = this.parentView.name; + // @ts-ignore + return (parentName ? (parentName + ".") : "") + this.props.name; + } + + public shouldComponentUpdate(): boolean { + // prevent component updates + return false; + } + + private get parentView(): ViewMetadata { + return this.props.viewMetadata; + } + + private getView(): ViewMetadata | undefined { + const fullName = this.fullName; + return this.parentView.childViews.items.find(c => c.name === fullName); + } + + public componentDidMount() { + const existingView = this.getView(); + if (existingView) { + // there's a view already rendered, insert in current frame's placeholder + this.replacement = existingView.placeholder; + this.placeholder.parentElement!.replaceChild(this.replacement, this.placeholder); + return; + } + + const id = this.generation; // for this purpose we can use generation (we just need a unique number) + const childView = newView(id, this.fullName, false, this.placeholder); + childView.generation = this.generation; + childView.parentView = this.parentView; + childView.context = this.props.context; + + const loadedHandler = this.props.loaded; + if (loadedHandler) { + childView.viewLoadTask.promise.then(() => loadedHandler()); + } + + this.parentView.childViews.add(childView); + } + + public componentWillUnmount() { + if (this.replacement) { + // put back the original container, otherwise react will complain + this.replacement.parentElement!.replaceChild(this.placeholder, this.replacement); + } + + const existingView = this.getView(); + if (existingView && this.generation === existingView.generation) { + // this is the most recent frame - meaning it was not replaced by another one - so the view should be removed + this.parentView.childViews.remove(existingView); + } + } + + public render() { + return
this.placeholder = e!} className={this.props.className} />; + } +} + +window["ViewFrameLegacy"] = { + ViewFrame: ViewFrameLegacy, + ViewSharedContext: ViewSharedContext +}; diff --git a/Sample.Avalonia/ExtendedReactViewFactory.cs b/Sample.Avalonia/ExtendedReactViewFactory.cs index 79324c0e..6a31a5f7 100644 --- a/Sample.Avalonia/ExtendedReactViewFactory.cs +++ b/Sample.Avalonia/ExtendedReactViewFactory.cs @@ -16,7 +16,7 @@ public override IViewModule[] InitializePlugins() { return new IViewModule[] { viewPlugin }; } - public override bool ShowDeveloperTools => false; + public override bool ShowDeveloperTools => true; public override bool EnableViewPreload => true; diff --git a/Sample.Avalonia/MainView/MainView.tsx b/Sample.Avalonia/MainView/MainView.tsx index efb6092a..57841f48 100644 --- a/Sample.Avalonia/MainView/MainView.tsx +++ b/Sample.Avalonia/MainView/MainView.tsx @@ -18,8 +18,10 @@ export enum BackgroundKind { // component properties ... the interface name must start with I prefix and end with Properties suffix export interface IMainViewProperties { + getInnerViewName(): string; getTasksCount(): Promise; taskListShown(): void; + innerViewEditorShown(): void; inputChanged(): void; addTaskButtonClicked(taskDetails: ITaskCreationDetails): void; readonly titleMessage: string; @@ -29,10 +31,11 @@ export interface IMainViewProperties { // component methods that can be called on .NET ... the interface name must start with I prefix and end with Behaviors suffix export interface IMainViewBehaviors { refresh(): void; + refreshInnerView(): void; } export interface IChildViews { - ListView: TaskListView; + ListView: TaskListView } enum TaskListShowStatus { @@ -44,6 +47,7 @@ enum TaskListShowStatus { interface MainViewState { tasksCount: number; taskListShowStatus: TaskListShowStatus; + editorViewName: string; } export default class MainView extends React.Component implements IMainViewBehaviors { @@ -59,12 +63,21 @@ export default class MainView extends React.Component { this.state = { + editorViewName: null, tasksCount: 0, taskListShowStatus: TaskListShowStatus.Show }; this.refresh(); } + public async refreshInnerView(): Promise { + console.log("Refreshing inner view..."); + const editorViewName = await this.props.getInnerViewName(); + + console.log("Refreshing inner view: Name: ", editorViewName); + this.setState({ editorViewName }, () => this.props.innerViewEditorShown()); + } + public refresh(): void { (async () => { const tasksCount = await this.props.getTasksCount(); @@ -118,6 +131,14 @@ export default class MainView extends React.Component; + } + public render(): JSX.Element { return (
@@ -127,6 +148,7 @@ export default class MainView extends React.ComponentShow/Block/Hide Tasks {this.renderListView()}
{this.state.tasksCount} task(s)
+ {this.renderEditor()}
); } diff --git a/Sample.Avalonia/MainView/MainViewAdapter.cs b/Sample.Avalonia/MainView/MainViewAdapter.cs new file mode 100644 index 00000000..79630363 --- /dev/null +++ b/Sample.Avalonia/MainView/MainViewAdapter.cs @@ -0,0 +1,24 @@ +using ReactViewControl; + +namespace Sample.Avalonia; + +partial class MainView { + private uint counter; + private string currentEditorViewName; + + public IViewModule ToggleEditorView() { + if (counter == 0) { + GetInnerViewName += () => currentEditorViewName; + } + + var childViewName = "canvas-" + counter++; + currentEditorViewName = childViewName; + + IViewModule view = counter % 2 == 0 + ? MainModule.GetOrAddChildView(childViewName) + : MainModule.GetOrAddChildView(childViewName); + + RefreshInnerView(); + return view; + } +} \ No newline at end of file diff --git a/Sample.Avalonia/MainWindow.xaml b/Sample.Avalonia/MainWindow.xaml index 0968b1b7..55276dd4 100644 --- a/Sample.Avalonia/MainWindow.xaml +++ b/Sample.Avalonia/MainWindow.xaml @@ -30,6 +30,7 @@ + diff --git a/Sample.Avalonia/MainWindow.xaml.cs b/Sample.Avalonia/MainWindow.xaml.cs index c10f7701..8b0bd8e7 100644 --- a/Sample.Avalonia/MainWindow.xaml.cs +++ b/Sample.Avalonia/MainWindow.xaml.cs @@ -72,6 +72,8 @@ public void CreateTab() { private void OnToggleThemeStyleSheetMenuItemClick(object sender, RoutedEventArgs e) => Settings.IsLightTheme = !Settings.IsLightTheme; private void OnShowDevToolsMenuItemClick(object sender, RoutedEventArgs e) => SelectedView.ShowDevTools(); + + private void OnToggleEditorView(object sender, RoutedEventArgs e) => SelectedView.ToggleCustomInnerView(); private void OnToggleIsEnabledMenuItemClick(object sender, RoutedEventArgs e) => SelectedView.ToggleIsEnabled(); diff --git a/Sample.Avalonia/TabView.cs b/Sample.Avalonia/TabView.cs index 198200a8..c138ff15 100644 --- a/Sample.Avalonia/TabView.cs +++ b/Sample.Avalonia/TabView.cs @@ -30,6 +30,9 @@ public TabView(int id) { mainView.AddTaskButtonClicked += OnMainViewAddTaskButtonClicked; mainView.GetTasksCount += () => taskList.Count; mainView.TaskListShown += () => taskListView.Load(); + mainView.InnerViewEditorShown += () => { + innerView.Load(); + }; mainView.WithPlugin().NotifyViewLoaded += viewName => AppendLog(viewName + " loaded"); taskListView = (TaskListViewModule)mainView.ListView; @@ -41,9 +44,12 @@ public TabView(int id) { taskListView.WithPlugin().NotifyViewLoaded += (viewName) => AppendLog(viewName + " loaded (child)"); taskListView.Load(); + innerView = mainView.ToggleEditorView(); Content = mainView; } + private IViewModule innerView; + private void OnMainViewAddTaskButtonClicked(TaskCreationDetails taskDetails) { taskList.Add(new Task() { id = taskCounter++, @@ -54,6 +60,10 @@ private void OnMainViewAddTaskButtonClicked(TaskCreationDetails taskDetails) { taskListView.Refresh(); // refresh task list AppendLog("Added task: " + taskDetails.text); } + + public void ToggleCustomInnerView() { + innerView = mainView.ToggleEditorView(); + } public void ToggleHideCompletedTasks() => taskListView.ToggleHideCompletedTasks(); diff --git a/Sample.Avalonia/TaskListView/TaskListView.tsx b/Sample.Avalonia/TaskListView/TaskListView.tsx index 341f602c..3f9b3cb8 100644 --- a/Sample.Avalonia/TaskListView/TaskListView.tsx +++ b/Sample.Avalonia/TaskListView/TaskListView.tsx @@ -71,7 +71,7 @@ export default class TaskListView extends React.Component { - const tasks = await this.props.getTasks(); + const tasks = (await this.props.getTasks()) || []; this.setState({ tasks }); })(); } diff --git a/Sample.Avalonia/UsersView/UsersView.scss b/Sample.Avalonia/UsersView/UsersView.scss new file mode 100644 index 00000000..734822fe --- /dev/null +++ b/Sample.Avalonia/UsersView/UsersView.scss @@ -0,0 +1,8 @@ +body { + background: none; + background: red; + width: 100px; + height: 100px; +} + + diff --git a/Sample.Avalonia/UsersView/UsersView.tsx b/Sample.Avalonia/UsersView/UsersView.tsx new file mode 100644 index 00000000..53f3239f --- /dev/null +++ b/Sample.Avalonia/UsersView/UsersView.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { IPluginsContext } from "PluginsProvider"; +import "./UsersView.scss"; + +export interface IUsersViewProperties { +} + +export interface IUsersViewBehaviors { + +} + +export default class UsersView extends React.Component implements IUsersViewBehaviors { + + constructor(props: IUsersViewProperties, context: IPluginsContext) { + super(props, context); + } + + public render(): JSX.Element { + return ( +
+ Hello World!! This is the users inner view! +
+ ); + } +}