Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

In-Memory Data Caching #27

Open
m-paul opened this issue Feb 23, 2022 · 12 comments
Open

In-Memory Data Caching #27

m-paul opened this issue Feb 23, 2022 · 12 comments

Comments

@m-paul
Copy link

m-paul commented Feb 23, 2022

I'm relatively new to React, so I might not be asking this question correctly, but is there a way to cache data at a more global level within Obsidian and access that data from a component?

For example, I created a test.md file with the following content:

function Counter(props) {
	const [count, setCount] = useState(0)
	return (
	<div>
	  <p>You clicked me {count} times!!!</p>
	  <button  onClick={() => setCount(count + 1)}>
		{props.source}
	  </button>hello
	</div>
	)
}
<Counter />

The component renders, and I can click the button incrementing the count, but if I click the button multiple times, sometimes the counter resets to 0, as if the component was re-rendered/reset. And, when I navigate away from the page and back, the counter is reset to 0. What I'd like to do is store the counter in a state that is persisted between reloads.

This is a basic use-case, but solving for this one would help me solve for a more complicated use-case. I am trying to build a component that will pull back data via an API, but only do so if the data isn't already cached - and where the cache persists even when navigating between pages or when the page is modified or re-rendered (but not necessarily when the app is restarted).

@elias-sundqvist
Copy link
Owner

Here is a custom hook I made some time ago:

defines-react-components:: true

```jsx:component:useStoredState
const ctx = useContext(ReactComponentContext);
var ppctx = ctx.markdownPostProcessorContext;
const [propertyName, defaultValue] = props;
const dv = app.plugins.plugins["dataview"].api;
const page = dv.page(ppctx.sourcePath);
const dataPath = page["data-path"];
const propertyDataPath = `${dataPath}/${propertyName}.json`
const [val, setVal] = useState(defaultValue);
const [timeoutID, setTimeoutID] = useState(-1);
let otherTimeoutID = timeoutID;

useEffect(async ()=>{
	if(!dataPath) {
		new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
		return null;
	}
	try {
		const newVal = await app.vault.readJson(propertyDataPath);
		if(val!=newVal && newVal!==null) {
			setVal(newVal);
		}
	} catch(e){}
	
}, []);

const setStoredValue = async val => {
	if(!dataPath) {
		new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
		return null;
	}
	try {
		await app.vault.createFolder(dataPath);
	} catch(e){}
	setVal(val);
	clearTimeout(timeoutID);
	clearTimeout(otherTimeoutID);
	const newTimeoutId = setTimeout(()=>{
		app.vault.writeJson(propertyDataPath, val);
	},2000);
	otherTimeoutID=newTimeoutId;
	setTimeoutID(newTimeoutId);
}
return [val, setStoredValue];
```

To use it, you need to have Dataview installed, and specify a location for the data like this:

data-path:: data/my-button


```jsx:
function Counter(props) {
	const [count, setCount] = useStoredState(["count",0])
	return (
	<div>
	  <p>You clicked me {count} times!!!</p>
	  <button  onClick={() => setCount(count + 1)}>
		{props.source}
	  </button>hello
	</div>
	)
}
<Counter />
```

I have not tested it extensively, so there may be some bugs, but this should hopefully get you started :)

@m-paul
Copy link
Author

m-paul commented Feb 23, 2022

Ah, I see. So instead of doing something in-memory, you're creating a json file that acts as a persisted data store.

Have a couple of questions:

  1. Why do you use two timeout ids?
  2. Are you using setTimeout so that the UI doesn't hang waiting for the JSON file to be written?

I believe obsidian recommends intervals created by setTimeout be registered with the plugin itself, so I'll try to see if I can wire that up too. (conflating setTimeout with setInterval)

@elias-sundqvist
Copy link
Owner

When I implemented this I was experimenting with making textboxes that stored their state. I felt that it would be unnecessarily taxing on my hard drive to write a new file every time I pressed a character on my keyboard, and it could also potentially degrade performance, so I made it wait until no new change has been made for 2 seconds before updating the file.

@elias-sundqvist
Copy link
Owner

elias-sundqvist commented Feb 23, 2022

Regarding the two timeout ids. When I made this I wasn't aware that you can get the current timeout state by calling setTimeOutId with a function, so I made this ugly workaround instead.

timeoutId represents the timeout id at the moment when the component was last rerendered.

otherTimeoutId represents the id of the timeout that this component most recently created.

I was too lazy to make any changes to the code. Sorry.

A less hacky version would look something like

defines-react-components:: true

```jsx:component:useStoredState
const ctx = useContext(ReactComponentContext);
var ppctx = ctx.markdownPostProcessorContext;
const [propertyName, defaultValue] = props;
const dv = app.plugins.plugins["dataview"].api;
const page = dv.page(ppctx.sourcePath);
const dataPath = page["data-path"];
const propertyDataPath = `${dataPath}/${propertyName}.json`
const [val, setVal] = useState(defaultValue);
const [timeoutId, setTimeoutId] = useState(-1);

useEffect(async ()=>{
	if(!dataPath) {
		new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
		return null;
	}
	try {
		const newVal = await app.vault.readJson(propertyDataPath);
		if(val!=newVal && newVal!==null) {
			setVal(newVal);
		}
	} catch(e){}
	
}, []);

const setStoredValue = async val => {
	if(!dataPath) {
		new obsidian.Notice("useStoredState requires the data-path property to be set for the note.");
		return null;
	}
	try {
		await app.vault.createFolder(dataPath);
	} catch(e){}
	setVal(val);
	setTimeoutId(timeoutId=>{
	    clearTimeout(timeoutId);
            return setTimeout(()=>{
		app.vault.writeJson(propertyDataPath, val);
	    },2000);
        });
}
return [val, setStoredValue];
```

@m-paul
Copy link
Author

m-paul commented Feb 24, 2022

I found a way to do this in-memory (since I don't care about persisting the data) by tacking on a variable to the plugin instance, though I'm not sure if it's a good idea.

const rxc = app.plugins.plugins["obsidian-react-components"];
if (!("cache" in rxc)) {
	rxc.cache = {};
}

const [propertyName, defaultValue] = props;
const [val, updateCache] = useState(defaultValue);

var sourcePath = useContext(ReactComponentContext).markdownPostProcessorContext.sourcePath;

useEffect(()=>{
	const newVal = rxc.cache?.[sourcePath]?.[propertyName];
	if ( val != newVal && newVal !== undefined && newVal !== null) {
		updateCache(newVal);
	}
}, []);

return [
	val, 
	val => {
		if (!(sourcePath in rxc.cache)) {
			rxc.cache[sourcePath] = {}
		}
		rxc.cache[sourcePath][propertyName] = val;
		updateCache(val);
	}
];

defines-react-components:: true


function Counter(props) {
	const [count, setCount] = useStoredState(["count",0])
	return (
	<div>
	  <p>You clicked me {count} times!!! {JSON.stringify(props)}</p>
	 [<button  onClick={() => setCount(count + 1)}>
		{props.source}
	  </button>]
	</div>
	)
}
<Counter />

@elias-sundqvist
Copy link
Owner

Yeah. On the off chance that I add a property called cache to the plugin, this could interfere, so it might be better to just do window.reactCache or something. But otherwise this solution should work fine if you don't care about things persisting when you restart obsidian.

I made something similar before , the only difference is that all components referencing the property will be re-rendered when the state is changed:

```jsx:component:useGlobalState
const [value, setValue] = useState(null);

const propertyName = props;
global.globalReactStateListeners = 
	global.globalReactStateListeners??new Map();

global.globalReactState = global.globalReactState ?? new Map();

useEffect(()=>{

   if(global.globalReactState.has(propertyName)) {
  		setValue(global.globalReactState.get(propertyName))
   }	if(!global.globalReactStateListeners.has(propertyName)) {
		global.globalReactStateListeners.set(propertyName, new Set());
	}
	global.globalReactStateListeners.get(propertyName).add(setValue);
  return ()=>{global.globalReactStateListeners.get(propertyName).delete(setValue);}
})

function setGlobalValue(val) {
	global.globalReactState.set(propertyName, val);
	for(const setter of global.globalReactStateListeners.get(propertyName)) {
	setter(val);
  }
}

return [value, setGlobalValue];
```

@m-paul
Copy link
Author

m-paul commented Mar 2, 2022

Any thoughts on how I might inject a global provider? I tried using a react-query provider with context sharing enabled but each instance of my component across pages still needed up with their own provider.

@elias-sundqvist
Copy link
Owner

I can't think of a way to do that on the top of my head, but it sounds like a reasonable feature request.

Do you have a proposal for how it could work?

@m-paul
Copy link
Author

m-paul commented Mar 2, 2022

Perhaps conceptually, but I'm still working my way through the code and piecing it all together. What I'm saying here might not make sense lol. I have three ideas, with three levels of complexity.

(1) Simpliest approach might be the best - just a single App-level wrapper for all pages. Maybe in the vein of how Header Components work but expanded so that you can specify header and footers and wrap all components on pages.

(2) A little more complicated approach would be to allow each page to specify which component it wants to wrap the page (and this component would live for the lifetime of the app) - what way you can have multiple types of wrappers depending on the page, or a default one to fall back on.

(3) Conceptually, an advanced option might look like this:

  • I can see the plugin having a global react App component. If a page has a react component, this react component would exist within a Page component, which resides in the App component. The Page component is loaded and unloaded as the page is displayed and not displayed, but the App lives for the lifetime of the plugin.
  • When a component on a page is loaded, it registers a App level component\provider with the App or a page level component\provider with the Page (provider being created if not already, and returned to the component) - this component can be another user defined react component or something else (like a query provider). Components registered with the Page are unloaded as the page is unloaded; but those with the App stay for the lifetime of the App unless the component can be called explicitly to unload/reload.

I can see this approach might lead to duplicative code or a question of what order page level components should be loaded if there are dependencies. One way of managing this would be for the page level components or app level components to call a common resolver behind the scenes (function defined in the plugin settings similar to how the javascript-init does), that is provided the component being requested, and have business logic to init dependent components if not already).

@m-paul
Copy link
Author

m-paul commented Mar 15, 2022

I found a way to use react-query, but it involves an intermediary component. An alternative implementation could make use of react portals (if I wanted to go the way of embedding a single page app into the page).

What I wish I could do is specify the name of a pre-processor function in the component page's front-matter such that, when reactToWebComponent is called, the pre-processor is passed the react component, its name, and page information (name and front-matter), can programmatically wrap the component however it would like and return the component back for it to be rendered on the page. That would remove the need for a wrapper.

Just nice to have though. It all works as is.

Screenshot

image

Example Usage

`jsx:<Jira issue="ARC-550" title="AWS SSO" aria-label-position="top" aria-label="ARC-550" />`

Implementation


defines-react-components: true

```jsx:component:getCache
import { QueryClient } from 'https://cdn.skypack.dev/react-query';

const rxc = app.plugins.plugins["obsidian-react-components"];

if (!("m-paul" in rxc)) {
	let queryClient = new QueryClient({
		defaultOptions: {
			queries: {
				staleTime: 1000 * 60 * 5, // 5 minutes
				cacheTime: 1000 * 60 * 5, // 5 minutes
				refetchOnMount: true, // only if stale
				refetchOnWindowFocus: true, // only if stale
				refetchOnReconnect: true, // only if stale
				refetchInterval: 1000 * 60 * 60, // 1 hour
				refetchIntervalInBackground: false,	// refresh if app in focus
				suspense: false, // don't suspend or raise errors
			}
		}
	});
	rxc["m-paul"]= {
		queryClient,
		refreshReactComponents: () => {
			app.workspace.trigger('react-components:component-updated')
		},
		refreshQueries: (...params) => {
			queryClient.invalidateQueries(...params);
		}
	};
}

return rxc["m-paul"];
```

```jsx:component:Jira
import { QueryClientProvider } from 'https://cdn.skypack.dev/react-query';
return (
<QueryClientProvider client={getCache().queryClient} contextSharing={true}>
	<_Jira {...props} />
</QueryClientProvider>
)
```

```jsx:component:_Jira
import { useQuery } from 'https://cdn.skypack.dev/react-query';
import styled from 'https://cdn.skypack.dev/styled-components';

const API = "https://example.atlassian.net";
const TOKEN = new Buffer("username@email.net:REDACTED_CREDENTIALS").toString("base64);
const CACHE = getCache();

if (props?.refresh == true) {
	const RefreshButton = styled.button`
		color: black;
		padding: 10px;
		margin: 0px 5px 0px 5px;
		box-shadow: 0 5px 5px rgba(9,30,66,0.25);
		:hover { background: lightblue; }
	`;

	function refresh() {
		CACHE.refreshQueries("jira");
	}

	return <RefreshButton onClick={refresh}>Refresh All</RefreshButton>;
}

const jiraDefaults = (() => {
	let url = API + "/rest/api/2/universal_avatar/view/type/issuetype/avatar";
	
	let grey;
	let blue;
	return {
		types: {
			default: { url: url + "/10320?size=medium" },
			story: { url: url + "/10315?size=medium" },
			bug: { url: url + "/10303?size=medium" },
			epic: { url: url + "/10307?size=medium" },
			task: { url: url + "/10318?size=medium" },
			subtask: { url: url + "/10316?size=medium" },
		},
		statuses: {
			default: (grey = {
				color: "rgb(66, 82, 110)",
				"background-color": "rgb(223, 225, 230)"
			}),
			backlog: grey,
			ready: grey,

			dev: (blue = {
				color: "rgb(7, 71, 166)",
				"background-color": "rgb(222, 235, 255)"
			}),
			qa: blue,
			ready_for_acceptance: blue,

			blocked: {
				color: "#7D2828",
				"background-color": "#F9DADA"
			},

			done: {
				color: "rgb(0, 102, 68)",
				"background-color": "rgb(227, 252, 239)"
			},
		}
	}
})();

function makeIssue(id, attrs, data=null) {
	// attributes take precedence
	let title = attrs?.title;
	let type = attrs?.type;
	let icon = attrs?.icon;
	let status = attrs?.status;

	// show loading when no title provided
	if (data == null) {
		if (title == null) {
			title = "Loading..."
		} else {
			status = "Loading..."
		}
	}

	// fill in rest with server values
	if (data != null) {
		title = title ?? data?.fields?.summary;
		type = type ?? data?.fields?.issuetype?.name;
		status = status ?? data?.fields?.status?.name;
	}

	let t = (type ?? "default").toLowerCase().replaceAll("-","");
	let s = (status ?? "default").toLowerCase().replaceAll(" ","_");

	return {
		id: id,
		url: `${API}/browse/${id}`,
		title: title,
		type: {
			name: type ?? "UNKNOWN",
			url: icon ?? (jiraDefaults.types?.[t] ?? jiraDefaults.types.default).url
		},
		status: {
			name: status ?? "UNKNOWN",
			...(jiraDefaults.statuses?.[s] ?? jiraDefaults.statuses.default)
		}
	}
}

const { isFetching, isLoading, isError, data, error } = useQuery(
	['jira', props.issue], 
	() => (
		obsidian.request({
			method: 'get',
			url: `${API}/rest/api/latest/issue/${props.issue}`,
			headers: {
				'Content-Type': 'application/json',
				"Authorization": `Basic ${TOKEN}`
			}
		})
		.then(content => {
			console.info(`fetched ${props.issue}`);
			return JSON.parse(content);
		})
		.catch(err => { console.error(err) })
	)
);

if (isError) { 
	throw error.message;
}

var issue = makeIssue(props.issue, props, (isLoading || isFetching) ? null : data);

// define styles
const Container = styled.span`.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { line-height: 2; }`;
const Anchor = styled.a`text-decoration: none; padding: 1px 0.24em 2px; display: inline; border-radius: 3px; color: rgb(0, 82, 204); box-shadow: 0 1px 1px rgba(9,30,66,0.25); cursor: pointer; transition: all 0.1s ease-in-out 0s;`;
const DetailWrapper = styled.span`hyphens: auto; white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word;`;
const IconWrapper = styled.span`margin-right: 4px; position: relative; display: inline-block;`;
const IconSpacer = styled.span`width: 14px; height: 100%; display: inline-block; opacity: 0;`;
const Icon = styled.img`height: 14px; width: 14px; margin-right: 4px; border-radius: 2px; user-select: none; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);`;
const StatusWrapper = styled.span`display: inline-block; vertical-align: 1px;`;
const StatusBlock = styled.span`
margin-left: 4px; background-color: ${issue.status["background-color"]}; color: ${issue.status.color}; display: inline-block; box-sizing: border-box; max-width: 100%;
padding: 2px 0px 3px; border-radius: 3px; font-weight: 700; font-size: 11px; line-height: 1; text-transform: uppercase; vertical-align: baseline;
.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { font-weight: 600; font-family: Tahoma; font-size: 8px; padding: 1px 0px 2px; }
`;
const Status = styled.span`display: inline-block; box-sizing: border-box; width: 100%; padding: 0px 4px; overflow: hidden; text-overflow: ellipsis; vertical-align: top; white-space: nowrap;`;
const DebugBtn = styled.button`
line-height: 0px; color: white; padding: 5px 5px 5px 5px; border-radius: 50px; margin: 0px 5px 0px 5px; box-shadow: 0 2px 2px rgba(9,30,66,0.25); :hover { background: lightblue; } display:none;
${Container}:hover & { display:inline; }
.workspace > .workspace-split:not(.mod-root) .markdown-preview-view & { font-weight: 600; font-family: Tahoma; font-size: 8px; }
`;

const RefreshBtn = <DebugBtn 
	onClick={() => {
		CACHE.refreshQueries(['jira', props.issue]);
	}}>
	<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="10" height="10">
		<path fill="none" d="M0 0h24v24H0z"/>
		<path d="M5.463 4.433A9.961 9.961 0 0 1 12 2c5.523 0 10 4.477 10 10 0 2.136-.67 4.116-1.81 5.74L17 12h3A8 8 0 0 0 6.46 6.228l-.997-1.795zm13.074 15.134A9.961 9.961 0 0 1 12 22C6.477 22 2 17.523 2 12c0-2.136.67-4.116 1.81-5.74L7 12H4a8 8 0 0 0 13.54 5.772l.997 1.795z" fill="rgba(0,0,0,1)"/>
	</svg>
</DebugBtn>

const title = (props?.title == null) ? `${issue.id}: ${issue.title}` : props.title;

return (
<Container {...props}>
	<Anchor href={issue.url}>
		<DetailWrapper>
			<IconWrapper>
				<IconSpacer/>
				<Icon src={issue.type.url}/>
			</IconWrapper>
			{title}
		</DetailWrapper>
		<StatusWrapper>
			<StatusBlock>
				<Status>
					{issue.status?.name}
				</Status>
			</StatusBlock>
		</StatusWrapper>
	</Anchor>
	{RefreshBtn}
	{props.debug ? <span>{JSON.stringify(issue)}</span>: null}
</Container>
)
```

@elias-sundqvist
Copy link
Owner

Nice use case :)

I have been busy with other things recently, but adding a wrapper using the frontmatter shouldn't be too difficult. I'll try to look into it soon.

@m-paul
Copy link
Author

m-paul commented Mar 17, 2022

Hey, I'm running into an issue in Obsidian where the screen periodically becomes unresponsive with this plugin - eventually it starts to take input, but if I use the component above, walk away for a bit and come back, the screen doesn't respond to clicking or the cursor for about a minute or so. It's not happening during the fetching of data (I have logs for that). Not sure what the issue is. It doesn't occur when I disable the plugin.

I updated my code to using "register" against your plugin to remove the cached object, but that's doesn't solve for the issue. Not sure what's happening.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants