Skip to content

Commit

Permalink
Add web view state service and corresponding React hook (#545)
Browse files Browse the repository at this point in the history
  • Loading branch information
lyonsil authored Oct 13, 2023
1 parent 0d8a93b commit d551cd2
Show file tree
Hide file tree
Showing 9 changed files with 257 additions and 7 deletions.
9 changes: 7 additions & 2 deletions extensions/src/hello-world/web-views/hello-world.web-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,20 @@ papi
globalThis.webViewComponent = function HelloWorld() {
const test = useContext(TestContext) || "Context didn't work!! :(";

const [clicks, setClicks] = useState(0);
const [clicks, setClicks] = globalThis.useWebViewState<number>('clicks', 0);
const [rows, setRows] = useState(initializeRows());
const [selectedRows, setSelectedRows] = useState(new Set<Key>());
const [scrRef, setScrRef] = useSetting('platform.verseRef', defaultScrRef);

// Update the clicks when we are informed helloWorld has been run
useEvent(
'helloWorld.onHelloWorld',
useCallback(({ times }: HelloWorldEvent) => setClicks(times), []),
useCallback(
({ times }: HelloWorldEvent) => {
if (times > clicks) setClicks(times);
},
[clicks, setClicks],
),
);

const [echoResult] = usePromise(
Expand Down
61 changes: 59 additions & 2 deletions lib/papi-dts/papi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// <reference types="node" />
declare module 'shared/global-this.model' {
import { LogLevel } from 'electron-log';
import { FunctionComponent } from 'react';
import { FunctionComponent, Dispatch, SetStateAction } from 'react';
/**
* Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host)
*/
Expand All @@ -20,9 +20,30 @@ declare module 'shared/global-this.model' {
var logLevel: LogLevel;
/**
* A function that each React WebView extension must provide for Paranext to display it.
* Only used in WebView iframes
* Only used in WebView iframes.
*/
var webViewComponent: FunctionComponent;
/**
* A React hook for working with a state object tied to a webview.
* Only used in WebView iframes.
* @param stateKey Key of the state value to use. The webview state holds a unique value per key.
* NOTE: `stateKey` needs to be a constant string, not something that could change during execution.
* @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey'
* @returns string holding the state value and a function to use to update the state value
* @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen');
*/
var useWebViewState: <T>(
stateKey: string,
defaultStateValue: NonNullable<T>,
) => [webViewState: NonNullable<T>, setWebViewState: Dispatch<SetStateAction<NonNullable<T>>>];
/**
* Retrieve the value from web view state with the given 'stateKey', if it exists.
*/
var getWebViewState: <T>(stateKey: string) => T | undefined;
/**
* Set the value for a given key in the web view state.
*/
var setWebViewState: <T>(stateKey: string, stateValue: NonNullable<T>) => void;
}
/** Type of Paranext process */
export enum ProcessType {
Expand Down Expand Up @@ -1725,6 +1746,8 @@ declare module 'shared/data/web-view.model' {
content: string;
/** Name of the tab for the WebView */
title?: string;
/** General object to store unique state for this webview */
state?: Record<string, string>;
};
/** WebView representation using React */
export type WebViewDefinitionReact = WebViewDefinitionBase & {
Expand Down Expand Up @@ -1907,6 +1930,40 @@ declare module 'shared/log-error.model' {
constructor(message?: string);
}
}
declare module 'renderer/services/web-view-state.service' {
/**
* Get the web view state associated with the given ID
* This function is only intended to be used at startup. getWebViewState is intended for web views to call.
* @param id ID of the web view
* @returns state object of the given web view
*/
export function getFullWebViewStateById(id: string): Record<string, string>;
/**
* Set the web view state associated with the given ID
* This function is only intended to be used at startup. setWebViewState is intended for web views to call.
* @param id ID of the web view
* @param state State to set for the given web view
*/
export function setFullWebViewStateById(id: string, state: Record<string, string>): void;
/**
* Get the web view state associated with the given ID
* @param id ID of the web view
* @param stateKey Key used to retrieve the state value
* @returns string (if it exists) containing the state for the given key of the given web view
*/
export function getWebViewStateById<T>(id: string, stateKey: string): T | undefined;
/**
* Set the web view state object associated with the given ID
* @param id ID of the web view
* @param stateKey Key for the associated state
* @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse
*/
export function setWebViewStateById<T>(id: string, stateKey: string, stateValue: T): void;
/** Purge any web view state that hasn't been touched since the process has been running.
* Only call this once all web views have been loaded.
*/
export function cleanupOldWebViewState(): void;
}
declare module 'shared/services/web-view.service' {
import { Unsubscriber } from 'shared/utils/papi-util';
import { MutableRefObject } from 'react';
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/global-this.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import * as SillsdevScripture from '@sillsdev/scripture';
import { ProcessType } from '@shared/global-this.model';
import papi, { Papi } from '@renderer/services/papi-frontend.service';
import { getModuleSimilarApiMessage } from '@shared/utils/papi-util';
import {
getWebViewStateById,
setWebViewStateById,
} from '@renderer/services/web-view-state.service';
import useWebViewState from '@renderer/hooks/use-webview-state';

// #region webpack DefinePlugin types setup - these should be from the renderer webpack DefinePlugin

Expand Down Expand Up @@ -57,6 +62,9 @@ declare global {
var createRoot: typeof ReactDOMClient.createRoot;
var SillsdevScripture: SillsdevScriptureType;
var webViewRequire: WebViewRequire;
// Web view state functions are used in the default imports for each webview in web-view.service.ts
var getWebViewStateById: <T>(id: string, stateKey: string) => T | undefined;
var setWebViewStateById: <T>(id: string, stateKey: string, stateValue: NonNullable<T>) => void;
}
/* eslint-enable */

Expand All @@ -81,5 +89,10 @@ globalThis.ReactDOMClient = ReactDOMClient;
globalThis.createRoot = ReactDOMClient.createRoot;
globalThis.SillsdevScripture = SillsdevScripture;
globalThis.webViewRequire = webViewRequire;
// We don't expose get/setWebViewStateById in PAPI because web views don't have access to IDs
globalThis.getWebViewStateById = getWebViewStateById;
globalThis.setWebViewStateById = setWebViewStateById;
// We store the hook reference because we need it to bind it to the webview's iframe 'window' context
globalThis.useWebViewState = useWebViewState;

// #endregion
21 changes: 21 additions & 0 deletions src/renderer/hooks/use-webview-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useState, useEffect, Dispatch, SetStateAction } from 'react';

// We don't add this to PAPI directly like other hooks because `this` has to be bound to a web view's iframe context
/** See src/shared/global-this.model.ts for normal hook documentation */
export default function useWebViewState<T>(
this: {
getWebViewState: (stateKey: string) => T | undefined;
setWebViewState: (stateKey: string, stateValue: NonNullable<T>) => void;
},
stateKey: string,
defaultStateValue: NonNullable<T>,
): [webViewState: NonNullable<T>, setWebViewState: Dispatch<SetStateAction<NonNullable<T>>>] {
const [state, setState] = useState(() => this.getWebViewState(stateKey) ?? defaultStateValue);

// Whenever the state changes, save the updated value
useEffect(() => {
this.setWebViewState(stateKey, state);
}, [stateKey, state]);

return [state, setState];
}
6 changes: 6 additions & 0 deletions src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as webViewService from '@shared/services/web-view.service';
import logger from '@shared/services/logger.service';
import webViewProviderService from '@shared/services/web-view-provider.service';
import App from './app.component';
import { cleanupOldWebViewState } from './services/web-view-state.service';

logger.info('Starting renderer');

Expand All @@ -18,3 +19,8 @@ webViewService.initialize();
const container = document.getElementById('root');
const root = createRoot(container as HTMLElement);
root.render(<App />);

// This doesn't run if the renderer has an uncaught exception (which is a good thing)
window.addEventListener('beforeunload', () => {
cleanupOldWebViewState();
});
109 changes: 109 additions & 0 deletions src/renderer/services/web-view-state.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const WEBVIEW_STATE_KEY = 'web-view-state';
const stateMap = new Map<string, Record<string, string>>();
const idsLookedUp = new Set<string>();

function loadIfNeeded(): void {
// If we have any data or tried to look something up, we've already loaded
if (stateMap.size > 0 || idsLookedUp.size > 0) return;

const serializedState = localStorage.getItem(WEBVIEW_STATE_KEY);
if (!serializedState) return;

const entries = JSON.parse(serializedState) as [[string, Record<string, string>]];
entries.forEach(([key, value]) => {
if (key && value) stateMap.set(key, value);
});
}

function save(): void {
// If no one looked anything up, don't overwrite anything
if (idsLookedUp.size <= 0) return;

const stateToSave = JSON.stringify(Array.from(stateMap.entries()));
localStorage.setItem(WEBVIEW_STATE_KEY, stateToSave);
}

function getRecord(id: string): Record<string, string> {
loadIfNeeded();
idsLookedUp.add(id);

const savedState = stateMap.get(id);
if (savedState) return savedState;

const newState = {};
stateMap.set(id, newState);
return newState;
}

/**
* Get the web view state associated with the given ID
* This function is only intended to be used at startup. getWebViewState is intended for web views to call.
* @param id ID of the web view
* @returns state object of the given web view
*/
export function getFullWebViewStateById(id: string): Record<string, string> {
if (!id) throw new Error('id must be provided to get webview state');
return getRecord(id);
}

/**
* Set the web view state associated with the given ID
* This function is only intended to be used at startup. setWebViewState is intended for web views to call.
* @param id ID of the web view
* @param state State to set for the given web view
*/
export function setFullWebViewStateById(id: string, state: Record<string, string>): void {
if (!id || !state) throw new Error('id and state must be provided to set webview state');
loadIfNeeded();
idsLookedUp.add(id);
stateMap.set(id, state);
save();
}

/**
* Get the web view state associated with the given ID
* @param id ID of the web view
* @param stateKey Key used to retrieve the state value
* @returns string (if it exists) containing the state for the given key of the given web view
*/
export function getWebViewStateById<T>(id: string, stateKey: string): T | undefined {
if (!id || !stateKey) throw new Error('id and stateKey must be provided to get webview state');
const state: Record<string, string> = getRecord(id);
const stateValue: string | undefined = state[stateKey];
return stateValue ? (JSON.parse(stateValue) as T) : undefined;
}

/**
* Set the web view state object associated with the given ID
* @param id ID of the web view
* @param stateKey Key for the associated state
* @param stateValue Value of the state for the given key of the given web view - must work with JSON.stringify/parse
*/
export function setWebViewStateById<T>(id: string, stateKey: string, stateValue: T): void {
if (!id || !stateKey) throw new Error('id and stateKey must be provided to set webview state');
const stringifiedValue = JSON.stringify(stateValue);
try {
const roundTripped = JSON.parse(stringifiedValue);
const roundTrippedStringified = JSON.stringify(roundTripped);
if (stringifiedValue !== roundTrippedStringified) {
throw new Error(`roundtrip failure`);
}
} catch (err) {
throw new Error(`"${stateKey}" value cannot round trip with JSON.stringify and JSON.parse.`);
}

const state: Record<string, string> = getRecord(id);
state[stateKey] = stringifiedValue;
save();
}

/** Purge any web view state that hasn't been touched since the process has been running.
* Only call this once all web views have been loaded.
*/
export function cleanupOldWebViewState(): void {
if (stateMap.size <= 0 || idsLookedUp.size <= 0) return;
stateMap.forEach((_, id) => {
if (!idsLookedUp.has(id)) stateMap.delete(id);
});
save();
}
2 changes: 2 additions & 0 deletions src/shared/data/web-view.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ type WebViewDefinitionBase = {
content: string;
/** Name of the tab for the WebView */
title?: string;
/** General object to store unique state for this webview */
state?: Record<string, string>;
};

/** WebView representation using React */
Expand Down
25 changes: 23 additions & 2 deletions src/shared/global-this.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
/* eslint-disable no-var */

import { LogLevel } from 'electron-log';
import { FunctionComponent } from 'react';
import { FunctionComponent, Dispatch, SetStateAction } from 'react';

/**
* Variables that are defined in global scope. These must be defined in main.ts (main), index.ts (renderer), and extension-host.ts (extension host)
Expand All @@ -21,9 +21,30 @@ declare global {
var logLevel: LogLevel;
/**
* A function that each React WebView extension must provide for Paranext to display it.
* Only used in WebView iframes
* Only used in WebView iframes.
*/
var webViewComponent: FunctionComponent;
/**
* A React hook for working with a state object tied to a webview.
* Only used in WebView iframes.
* @param stateKey Key of the state value to use. The webview state holds a unique value per key.
* NOTE: `stateKey` needs to be a constant string, not something that could change during execution.
* @param defaultStateValue Value to use if the web view state didn't contain a value for the given 'stateKey'
* @returns string holding the state value and a function to use to update the state value
* @example const [lastPersonSeen, setLastPersonSeen] = useWebViewState('lastSeen');
*/
var useWebViewState: <T>(
stateKey: string,
defaultStateValue: NonNullable<T>,
) => [webViewState: NonNullable<T>, setWebViewState: Dispatch<SetStateAction<NonNullable<T>>>];
/**
* Retrieve the value from web view state with the given 'stateKey', if it exists.
*/
var getWebViewState: <T>(stateKey: string) => T | undefined;
/**
* Set the value for a given key in the web view state.
*/
var setWebViewState: <T>(stateKey: string, stateValue: NonNullable<T>) => void;
}

/** Type of Paranext process */
Expand Down
18 changes: 17 additions & 1 deletion src/shared/services/web-view.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import AsyncVariable from '@shared/utils/async-variable';
import logger from '@shared/services/logger.service';
import LogError from '@shared/log-error.model';
import memoizeOne from 'memoize-one';
import {
getFullWebViewStateById,
setFullWebViewStateById,
} from '@renderer/services/web-view-state.service';

/** rc-dock's onLayoutChange prop made asynchronous - resolves */
export type OnLayoutChangeRCDock = (
Expand Down Expand Up @@ -423,6 +427,8 @@ export const getWebView = async (
existingSavedWebView = convertWebViewDefinitionToSaved(
existingWebView.data as WebViewDefinition,
);
// Load the web view state since the web view provider doesn't have access to the data store
existingSavedWebView.state = getFullWebViewStateById(existingWebView.id);
didFindExistingWebView = true;
}
}
Expand All @@ -441,6 +447,9 @@ export const getWebView = async (
// The web view provider didn't want to create this web view
if (!webView) return undefined;

// The web view provider might have updated the web view state, so save it
if (webView.state) setFullWebViewStateById(webView.id, webView.state);

/**
* The web view we are getting is new. Either the webview provider gave us a new webview instead
* of the existing one or there wasn't an existing one in the first place
Expand All @@ -457,7 +466,9 @@ export const getWebView = async (
// WebView.contentType is assumed to be React by default. Extensions can specify otherwise
const contentType = webView.contentType ? webView.contentType : WebViewContentType.React;

// Note: `webViewRequire` below is defined in `src\renderer\global-this.model.ts`.
// `webViewRequire`, `getWebViewStateById`, and `setWebViewStateById` below are defined in `src\renderer\global-this.model.ts`
// `useWebViewState` below is defined in `src\shared\global-this.model.ts`
// We have to bind `useWebViewState` to the current `window` context because calls within PAPI don't have access to a webview's `window` context
/** String that sets up 'import' statements in the webview to pull in libraries and clear out internet access and such */
const imports = `
var papi = window.parent.papi;
Expand All @@ -468,6 +479,11 @@ export const getWebView = async (
var createRoot = window.parent.createRoot;
var SillsdevScripture = window.parent.SillsdevScripture;
var require = window.parent.webViewRequire;
var getWebViewStateById = window.parent.getWebViewStateById;
var setWebViewStateById = window.parent.setWebViewStateById;
window.getWebViewState = (stateKey) => { return getWebViewStateById('${webView.id}', stateKey) };
window.setWebViewState = (stateKey, stateValue) => { setWebViewStateById('${webView.id}', stateKey, stateValue) };
window.useWebViewState = window.parent.useWebViewState.bind(window);
window.fetch = papi.fetch;
delete window.parent;
delete window.top;
Expand Down

0 comments on commit d551cd2

Please sign in to comment.