Component view refactor (#770)

* refactor: simplify component-view lifecycle callbacks

* fix: reintroduce exhaustive-deps
This commit is contained in:
Mo
2021-12-13 11:16:14 -06:00
committed by GitHub
parent d5f75fee84
commit a15014f003
5 changed files with 201 additions and 216 deletions

View File

@@ -19,8 +19,8 @@
"semi": 1, "semi": 1,
"camelcase": "warn", "camelcase": "warn",
"sort-imports": "off", "sort-imports": "off",
"react-hooks/rules-of-hooks": "error", // Checks rules of Hooks "react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error", // Checks effect dependencies "react-hooks/exhaustive-deps": "error",
"eol-last": "error", "eol-last": "error",
"no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }],
"no-trailing-spaces": "error" "no-trailing-spaces": "error"

View File

@@ -6,16 +6,16 @@ interface IProps {
} }
export const IssueOnLoading: FunctionalComponent<IProps> = ({ export const IssueOnLoading: FunctionalComponent<IProps> = ({
componentName, componentName,
reloadIframe reloadIframe,
}) => { }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-app-bar no-edges no-top-edge dynamic-height'}> <div className={'sk-app-bar no-edges no-top-edge dynamic-height'}>
<div className={'left'}> <div className={'left'}>
<div className={'sk-app-bar-item'}> <div className={'sk-app-bar-item'}>
<div className={'sk-label.warning'}> <div className={'sk-label.warning'}>
There was an issue loading {componentName} There was an issue loading {componentName}.
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,9 +6,9 @@ interface IProps {
} }
export const OfflineRestricted: FunctionalComponent<IProps> = ({ export const OfflineRestricted: FunctionalComponent<IProps> = ({
isReloading, isReloading,
reloadStatus reloadStatus,
}) => { }) => {
return ( return (
<div className={'sn-component'}> <div className={'sn-component'}>
<div className={'sk-panel static'}> <div className={'sk-panel static'}>
@@ -16,39 +16,37 @@ export const OfflineRestricted: FunctionalComponent<IProps> = ({
<div className={'sk-panel-section stretch'}> <div className={'sk-panel-section stretch'}>
<div className={'sk-panel-column'} /> <div className={'sk-panel-column'} />
<div className={'sk-h1 sk-bold'}> <div className={'sk-h1 sk-bold'}>
You have restricted this extension to be used offline only. You have restricted this component to be used offline only.
</div> </div>
<div className={'sk-subtitle'}> <div className={'sk-subtitle'}>
Offline extensions are not available in the Web app. Offline components are not available in the web application.
</div> </div>
<div className={'sk-panel-row'} /> <div className={'sk-panel-row'} />
<div className={'sk-panel-row'}> <div className={'sk-panel-row'}>
<div className={'sk-panel-column'}> <div className={'sk-panel-column'}>
<div className={'sk-p'}> <div className={'sk-p'}>You can either:</div>
You can either:
</div>
<ul> <ul>
<li className={'sk-p'}> <li className={'sk-p'}>
<span className={'font-bold'}> Enable the Hosted option for this component by opening
Enable the Hosted option for this extension by opening the 'Extensions' menu and{' '} Preferences {'>'} General {'>'} Advanced Settings menu and{' '}
toggling 'Use hosted when local is unavailable' under this extension's options.{' '} toggling 'Use hosted when local is unavailable' under this
Then press Reload below. components's options. Then press Reload below.
</span>
</li>
<li className={'sk-p'}>
<span className={'font-bold'}>Use the Desktop application.</span>
</li> </li>
<li className={'sk-p'}>Use the desktop application.</li>
</ul> </ul>
</div> </div>
</div> </div>
<div className={'sk-panel-row'}> <div className={'sk-panel-row'}>
{isReloading ? {isReloading ? (
<div className={'sk-spinner info small'} /> <div className={'sk-spinner info small'} />
: ) : (
<button className={'sn-button small info'} onClick={() => reloadStatus()}> <button
className={'sn-button small info'}
onClick={() => reloadStatus()}
>
Reload Reload
</button> </button>
} )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,10 @@
import { ComponentAction, FeatureStatus, LiveItem, SNComponent, dateToLocalizedString } from '@standardnotes/snjs'; import {
ComponentAction,
FeatureStatus,
SNComponent,
dateToLocalizedString,
ApplicationEvent,
} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application'; import { WebApplication } from '@/ui_models/application';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
import { toDirective } from '@/components/utils'; import { toDirective } from '@/components/utils';
@@ -11,7 +17,6 @@ import { IsDeprecated } from '@/components/ComponentView/IsDeprecated';
import { IsExpired } from '@/components/ComponentView/IsExpired'; import { IsExpired } from '@/components/ComponentView/IsExpired';
import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading'; import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import { ComponentArea } from '@node_modules/@standardnotes/features';
import { openSubscriptionDashboard } from '@/hooks/manageSubscription'; import { openSubscriptionDashboard } from '@/hooks/manageSubscription';
interface IProps { interface IProps {
@@ -32,31 +37,37 @@ const VisibilityChangeKey = 'visibilitychange';
const avoidFlickerTimeout = 7; const avoidFlickerTimeout = 7;
export const ComponentView: FunctionalComponent<IProps> = observer( export const ComponentView: FunctionalComponent<IProps> = observer(
({ ({ application, onLoad, componentUuid, templateComponent }) => {
application,
onLoad,
componentUuid,
templateComponent
}) => {
const liveComponentRef = useRef<LiveItem<SNComponent> | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const excessiveLoadingTimeout = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
const [isIssueOnLoading, setIsIssueOnLoading] = useState(false); const [hasIssueLoading, setHasIssueLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(true);
const [isReloading, setIsReloading] = useState(false); const [isReloading, setIsReloading] = useState(false);
const [loadTimeout, setLoadTimeout] = useState<ReturnType<typeof setTimeout> | undefined>(undefined); const [component] = useState<SNComponent>(
const [featureStatus, setFeatureStatus] = useState<FeatureStatus | undefined>(FeatureStatus.Entitled); application.findItem(componentUuid) as SNComponent
);
const [featureStatus, setFeatureStatus] = useState<FeatureStatus>(
application.getFeatureStatus(component.identifier)
);
const [isComponentValid, setIsComponentValid] = useState(true); const [isComponentValid, setIsComponentValid] = useState(true);
const [error, setError] = useState<'offline-restricted' | 'url-missing' | undefined>(undefined); const [error, setError] = useState<
'offline-restricted' | 'url-missing' | undefined
>(undefined);
const [isDeprecated, setIsDeprecated] = useState(false); const [isDeprecated, setIsDeprecated] = useState(false);
const [deprecationMessage, setDeprecationMessage] = useState<string | undefined>(undefined); const [deprecationMessage, setDeprecationMessage] = useState<
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false); string | undefined
>(undefined);
const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] =
useState(false);
const [didAttemptReload, setDidAttemptReload] = useState(false); const [didAttemptReload, setDidAttemptReload] = useState(false);
const [component, setComponent] = useState<SNComponent | undefined>(undefined); const [contentWindow, setContentWindow] = useState<Window | null>(null);
const getComponent = useCallback((): SNComponent => { const manageSubscription = useCallback(() => {
return (templateComponent || liveComponentRef.current?.item) as SNComponent; openSubscriptionDashboard(application);
}, [templateComponent]); }, [application]);
const reloadIframe = () => { const reloadIframe = () => {
setTimeout(() => { setTimeout(() => {
@@ -67,30 +78,37 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
}); });
}; };
const manageSubscription = useCallback(() => { useEffect(() => {
openSubscriptionDashboard(application); const loadTimeout = setTimeout(() => {
}, [application]); handleIframeTakingTooLongToLoad();
}, MaxLoadThreshold);
excessiveLoadingTimeout.current = loadTimeout;
return () => {
excessiveLoadingTimeout.current &&
clearTimeout(excessiveLoadingTimeout.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reloadStatus = useCallback(() => { const reloadValidityStatus = useCallback(() => {
if (!component) { const offlineRestricted =
return; component.offlineOnly && !isDesktopApplication();
} const hasUrlError = (function () {
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
const hasUrlError = function () {
if (isDesktopApplication()) { if (isDesktopApplication()) {
return !component.local_url && !component.hasValidHostedUrl(); return !component.local_url && !component.hasValidHostedUrl();
} else { } else {
return !component.hasValidHostedUrl(); return !component.hasValidHostedUrl();
} }
}(); })();
setFeatureStatus(application.getFeatureStatus(component.identifier)); const readonlyState =
application.componentManager.getReadonlyStateForComponent(component);
const readonlyState = application.componentManager.getReadonlyStateForComponent(component);
if (!readonlyState.lockReadonly) { if (!readonlyState.lockReadonly) {
application.componentManager.setReadonlyStateForComponent(component, featureStatus !== FeatureStatus.Entitled); application.componentManager.setReadonlyStateForComponent(
component,
featureStatus !== FeatureStatus.Entitled
);
} }
setIsComponentValid(!offlineRestricted && !hasUrlError); setIsComponentValid(!offlineRestricted && !hasUrlError);
@@ -107,195 +125,165 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
} }
setIsDeprecated(component.isDeprecated); setIsDeprecated(component.isDeprecated);
setDeprecationMessage(component.package_info.deprecation_message); setDeprecationMessage(component.package_info.deprecation_message);
}, [application, component, isComponentValid, featureStatus]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
reloadValidityStatus();
}, [reloadValidityStatus]);
const dismissDeprecationMessage = () => { const dismissDeprecationMessage = () => {
setTimeout(() => { setIsDeprecationMessageDismissed(true);
setIsDeprecationMessageDismissed(true);
});
}; };
const onVisibilityChange = useCallback(() => { const onVisibilityChange = useCallback(() => {
if (document.visibilityState === 'hidden') { if (document.visibilityState === 'hidden') {
return; return;
} }
if (isIssueOnLoading) { if (hasIssueLoading) {
reloadIframe(); reloadIframe();
} }
}, [isIssueOnLoading]); }, [hasIssueLoading]);
const handleIframeLoadTimeout = useCallback(async () => { const handleIframeTakingTooLongToLoad = useCallback(async () => {
if (isLoading) { setIsLoading(false);
setIsLoading(false); setHasIssueLoading(true);
setIsIssueOnLoading(true);
if (!didAttemptReload) { if (!didAttemptReload) {
setDidAttemptReload(true); setDidAttemptReload(true);
reloadIframe(); reloadIframe();
} else { } else {
document.addEventListener( document.addEventListener(VisibilityChangeKey, onVisibilityChange);
VisibilityChangeKey,
onVisibilityChange
);
}
} }
}, [didAttemptReload, isLoading, onVisibilityChange]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => { const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => {
if (!component) { let hasDesktopError = false;
return; const canAccessWindowOrigin = isDesktopApplication();
} if (canAccessWindowOrigin) {
let desktopError = false;
if (isDesktopApplication()) {
try { try {
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */ const contentWindow = iframe.contentWindow as Window;
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') { if (!contentWindow.origin || contentWindow.origin === 'null') {
desktopError = true; hasDesktopError = true;
} }
// eslint-disable-next-line no-empty // eslint-disable-next-line no-empty
} catch (e) { } catch (e) {}
}
} }
loadTimeout && clearTimeout(loadTimeout); excessiveLoadingTimeout.current &&
await application.componentManager.registerComponentWindow( clearTimeout(excessiveLoadingTimeout.current);
component, setContentWindow(iframe.contentWindow);
iframe.contentWindow!
);
setTimeout(() => { setTimeout(() => {
setIsLoading(false); setIsLoading(false);
setIsIssueOnLoading(desktopError ? true : false); setHasIssueLoading(hasDesktopError);
onLoad?.(component!); onLoad?.(component);
}, avoidFlickerTimeout); }, avoidFlickerTimeout);
}, [application.componentManager, component, loadTimeout, onLoad]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadComponent = useCallback(() => {
if (!component) {
throw Error('Component view is missing component');
}
if (!component.active && !component.isEditor() && component.area !== ComponentArea.Modal) {
/** Editors don't need to be active to be displayed */
throw Error('Component view component must be active');
}
setIsLoading(true);
if (loadTimeout) {
clearTimeout(loadTimeout);
}
const timeoutHandler = setTimeout(() => {
handleIframeLoadTimeout();
}, MaxLoadThreshold);
setLoadTimeout(timeoutHandler);
}, [component, handleIframeLoadTimeout, loadTimeout]);
useEffect(() => { useEffect(() => {
reloadStatus(); if (contentWindow) {
application.componentManager.registerComponentWindow(
component,
contentWindow
);
}
return () => {
application.componentManager.onComponentIframeDestroyed(component.uuid);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [contentWindow]);
useEffect(() => {
if (!iframeRef.current) { if (!iframeRef.current) {
setContentWindow(null);
return; return;
} }
iframeRef.current.onload = () => { iframeRef.current.onload = () => {
if (!component) {
return;
}
const iframe = application.componentManager.iframeForComponent( const iframe = application.componentManager.iframeForComponent(
component.uuid component.uuid
); );
if (!iframe) { if (iframe) {
return; setTimeout(() => {
handleIframeLoad(iframe);
});
} }
setTimeout(() => {
loadComponent();
reloadStatus();
handleIframeLoad(iframe);
});
}; };
}, [application.componentManager, component, handleIframeLoad, loadComponent, reloadStatus]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [iframeRef.current]);
const getUrl = () => {
const url = component ? application.componentManager.urlForComponent(component) : '';
return url as string;
};
useEffect(() => { useEffect(() => {
if (componentUuid) { const removeFeaturesChangedObserver = application.addEventObserver(
liveComponentRef.current = new LiveItem(componentUuid, application); async () => {
} else { setFeatureStatus(application.getFeatureStatus(component.identifier));
application.componentManager.addTemporaryTemplateComponent(templateComponent as SNComponent); },
ApplicationEvent.FeaturesUpdated
);
return () => {
removeFeaturesChangedObserver();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!componentUuid) {
application.componentManager.addTemporaryTemplateComponent(
templateComponent as SNComponent
);
} }
return () => { return () => {
if (application.componentManager) { if (templateComponent) {
/** Component manager Can be destroyed already via locking */ /** componentManager can be destroyed already via locking */
if (component) { application.componentManager?.removeTemporaryTemplateComponent(
application.componentManager.onComponentIframeDestroyed(component.uuid); templateComponent
} );
if (templateComponent) {
application.componentManager.removeTemporaryTemplateComponent(templateComponent);
}
} }
if (liveComponentRef.current) { document.removeEventListener(VisibilityChangeKey, onVisibilityChange);
liveComponentRef.current.deinit();
}
document.removeEventListener(
VisibilityChangeKey,
onVisibilityChange
);
}; };
}, [application, component, componentUuid, onVisibilityChange, templateComponent]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
// Set/update `component` based on `componentUuid` prop. const unregisterComponentHandler =
// It's a hint that the props were changed and we should rerender this component (and particularly, the iframe). application.componentManager.registerHandler({
if (!component || component.uuid && componentUuid && component.uuid !== componentUuid) { identifier: 'component-view-' + Math.random(),
const latestComponentValue = getComponent(); areas: [component.area],
setComponent(latestComponentValue); actionHandler: (component, action, data) => {
} switch (action) {
}, [component, componentUuid, getComponent]); case ComponentAction.SetSize:
application.componentManager.handleSetSizeEvent(
useEffect(() => { component,
if (!component) { data
return; );
} break;
case ComponentAction.KeyDown:
const unregisterComponentHandler = application.componentManager.registerHandler({ application.io.handleComponentKeyDown(data.keyboardModifier);
identifier: 'component-view-' + Math.random(), break;
areas: [component.area], case ComponentAction.KeyUp:
actionHandler: (component, action, data) => { application.io.handleComponentKeyUp(data.keyboardModifier);
switch (action) { break;
case (ComponentAction.SetSize): case ComponentAction.Click:
application.componentManager.handleSetSizeEvent(component, data); application.getAppState().notes.setContextMenuOpen(false);
break; break;
case (ComponentAction.KeyDown): default:
application.io.handleComponentKeyDown(data.keyboardModifier); return;
break; }
case (ComponentAction.KeyUp): },
application.io.handleComponentKeyUp(data.keyboardModifier); });
break;
case (ComponentAction.Click):
application.getAppState().notes.setContextMenuOpen(false);
break;
default:
return;
}
}
});
return () => { return () => {
unregisterComponentHandler(); unregisterComponentHandler();
}; };
}, [application, component]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [component]);
useEffect(() => { useEffect(() => {
const unregisterDesktopObserver = application.getDesktopService() const unregisterDesktopObserver = application
.getDesktopService()
.registerUpdateObserver((component: SNComponent) => { .registerUpdateObserver((component: SNComponent) => {
if (component.uuid === component.uuid && component.active) { if (component.uuid === component.uuid && component.active) {
reloadIframe(); reloadIframe();
@@ -307,13 +295,9 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
}; };
}, [application]); }, [application]);
if (!component) {
return null;
}
return ( return (
<> <>
{isIssueOnLoading && ( {hasIssueLoading && (
<IssueOnLoading <IssueOnLoading
componentName={component.name} componentName={component.name}
reloadIframe={reloadIframe} reloadIframe={reloadIframe}
@@ -322,8 +306,8 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
{featureStatus !== FeatureStatus.Entitled && ( {featureStatus !== FeatureStatus.Entitled && (
<IsExpired <IsExpired
expiredDate={dateToLocalizedString(component.valid_until)} expiredDate={dateToLocalizedString(component.valid_until)}
reloadStatus={reloadStatus} reloadStatus={reloadValidityStatus}
featureStatus={featureStatus!} featureStatus={featureStatus}
componentName={component.name} componentName={component.name}
manageSubscription={manageSubscription} manageSubscription={manageSubscription}
/> />
@@ -335,7 +319,10 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
/> />
)} )}
{error == 'offline-restricted' && ( {error == 'offline-restricted' && (
<OfflineRestricted isReloading={isReloading} reloadStatus={reloadStatus} /> <OfflineRestricted
isReloading={isReloading}
reloadStatus={reloadValidityStatus}
/>
)} )}
{error == 'url-missing' && ( {error == 'url-missing' && (
<UrlMissing componentName={component.name} /> <UrlMissing componentName={component.name} />
@@ -346,22 +333,21 @@ export const ComponentView: FunctionalComponent<IProps> = observer(
data-component-id={component.uuid} data-component-id={component.uuid}
frameBorder={0} frameBorder={0}
data-attr-id={`component-iframe-${component.uuid}`} data-attr-id={`component-iframe-${component.uuid}`}
src={getUrl()} src={application.componentManager.urlForComponent(component) || ''}
sandbox='allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads' sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-modals allow-forms allow-downloads"
> >
Loading Loading
</iframe> </iframe>
)} )}
{isLoading && ( {isLoading && <div className={'loading-overlay'} />}
<div className={'loading-overlay'} />
)}
</> </>
); );
}); }
);
export const ComponentViewDirective = toDirective<IProps>(ComponentView, { export const ComponentViewDirective = toDirective<IProps>(ComponentView, {
onLoad: '=', onLoad: '=',
componentUuid: '=', componentUuid: '=',
templateComponent: '=', templateComponent: '=',
manualDealloc: '=' manualDealloc: '=',
}); });

View File

@@ -174,7 +174,8 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.editorValues.text = note.text; this.editorValues.text = note.text;
} }
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadSource.Constructor && note.dirty; const isTemplateNoteInsertedToBeInteractableWithEditor =
source === PayloadSource.Constructor && note.dirty;
if (isTemplateNoteInsertedToBeInteractableWithEditor) { if (isTemplateNoteInsertedToBeInteractableWithEditor) {
return; return;
} }
@@ -396,7 +397,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
this.reloadFont(); this.reloadFont();
} else if (component.area === ComponentArea.Editor) { } else if (component.area === ComponentArea.Editor) {
const currentEditor = this.state.editorComponent; const currentEditor = this.state.editorComponent;
if (currentEditor && component !== currentEditor) { if (currentEditor && component.uuid !== currentEditor.uuid) {
await this.disassociateComponentWithCurrentNote(currentEditor); await this.disassociateComponentWithCurrentNote(currentEditor);
} }
const prefersPlain = this.note.prefersPlainEditor; const prefersPlain = this.note.prefersPlainEditor;