diff --git a/integration-tests/integration.test.js b/integration-tests/integration.test.js
index a8153ad..3c2bbfe 100644
--- a/integration-tests/integration.test.js
+++ b/integration-tests/integration.test.js
@@ -1,12 +1,11 @@
const { getByText, fireEvent, waitFor } = require('@testing-library/dom');
const { startApp } = require('./test-app');
-describe('Tram-One', () => {
- beforeEach(() => {
- // clean up any tram-one properties between tests
- window['tram-space'] = undefined;
- });
+/**
+ * The following tests are intentional test that validate the behavior of new features.
+ */
+describe('Tram-One', () => {
it('should render on a Node', () => {
// mount the app on the container
const container = document.createElement('div');
diff --git a/integration-tests/internals.test.js b/integration-tests/internals.test.js
new file mode 100644
index 0000000..f00683f
--- /dev/null
+++ b/integration-tests/internals.test.js
@@ -0,0 +1,70 @@
+const { queryByText, fireEvent, waitFor, getByLabelText } = require('@testing-library/dom');
+const { default: userEvent } = require('@testing-library/user-event');
+const { startAppAndWait } = require('./test-helpers');
+
+/**
+ * The following suite of tests verify the behavior of the internals of Tram-One, more so than other tests might.
+ * They are often inpercievable to end-users, and verify the expected behavior of the behind-the-scenes design.
+ */
+
+describe('Tram-One', () => {
+ it('should clean up stores for elements that are no longer rendered', async () => {
+ // start the app
+ const { container } = await startAppAndWait();
+
+ // previously stores made for elements that had been removed stayed in the tram-observable-store
+
+ const initialStores = Object.keys(window['tram-space']['tram-observable-store']);
+
+ // focus on the input (the range input defaults to 0)
+ userEvent.click(getByLabelText(container, 'Store Generator'));
+
+ // change the value of the input
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 1 } });
+
+ await waitFor(() => {
+ // make sure the new control is in the document
+ // (additionally, we're doing this to make sure that all the mutation observers have had a chance to catch up)
+ expect(queryByText(container, '[0: 0]')).toBeVisible();
+ });
+
+ // expect us to have one additional store now
+ const postChangeStores = Object.keys(window['tram-space']['tram-observable-store']);
+ expect(postChangeStores.length).toBe(initialStores.length + 1);
+
+ // change the value of the input back to 0
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 0 } });
+
+ await waitFor(() => {
+ // make sure the new control is in the document
+ // (additionally, we're doing this to make sure that all the mutation observers have had a chance to catch up)
+ expect(queryByText(container, '[0: 0]')).toBe(null);
+ });
+
+ // wait for mutation observer clean up removed stores
+ await waitFor(() => {
+ const postChangeStoresTwo = Object.keys(window['tram-space']['tram-observable-store']);
+ // check that the lists are the same (they may have shuffled, so sort them)
+ expect(postChangeStoresTwo.sort()).toEqual(initialStores.sort());
+ });
+ });
+
+ it('should not have recursive working-key branches', async () => {
+ // start the app
+ await startAppAndWait();
+
+ // previously the working branch indices would have long recursive chains of branches
+
+ const workingKeyBranches = Object.keys(window['tram-space']['tram-hook-key'].branchIndices);
+
+ // verify that top-level elements exist
+ expect(workingKeyBranches).toEqual(expect.arrayContaining(['app[{}]']));
+ expect(workingKeyBranches).toEqual(expect.arrayContaining(['app[{}]/logo[{}]']));
+ expect(workingKeyBranches).toEqual(expect.arrayContaining(['app[{}]/tab[{}]']));
+
+ // verify that no element contains a duplicate of 'app[{}]' - this indicates an issue with the key generation
+ workingKeyBranches.forEach((branch) => {
+ expect(branch).not.toMatch(/app\[\{\}\].*app\[\{\}\]/);
+ });
+ });
+});
diff --git a/integration-tests/regression.test.js b/integration-tests/regression.test.js
index d691d4d..baae48c 100644
--- a/integration-tests/regression.test.js
+++ b/integration-tests/regression.test.js
@@ -1,11 +1,16 @@
-const { getByText, queryAllByText, fireEvent, waitFor, getByLabelText } = require('@testing-library/dom');
+const { getByText, queryAllByText, fireEvent, waitFor, getByLabelText, queryByText } = require('@testing-library/dom');
const { default: userEvent } = require('@testing-library/user-event');
-const { startApp } = require('./test-app');
+const { startAppAndWait } = require('./test-helpers');
+
+/**
+ * The following suite of tests are made retroactively for unexpected behaviors.
+ * They are not for any direct feature, but rather validate the behavior of framework as a whole.
+ */
describe('Tram-One', () => {
it('should not call cleanups that are not functions', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously this would fail because the cleanup was called,
// even though it was not a function, and instead was a promise (the result of an async function)
@@ -18,7 +23,7 @@ describe('Tram-One', () => {
it('should call updated cleanups', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// verify that the tab is rendered and the lock button is there
expect(getByText(container, 'Was Locked: false')).toBeVisible();
@@ -48,7 +53,7 @@ describe('Tram-One', () => {
it('should process state as an array', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously when state was being processed, it would be converted to an object
// this test adds an element to a store to verify array methods work
@@ -67,7 +72,7 @@ describe('Tram-One', () => {
window.history.pushState({}, '', '/test_account');
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// verify the account info is read correctly at startup
expect(getByText(container, 'Account: test_account')).toBeVisible();
@@ -75,7 +80,7 @@ describe('Tram-One', () => {
it('should keep focus on inputs when components would rerender', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously when interacting with an input, if the component would rerender
// focus would be removed from the component and put on the body of the page
@@ -89,7 +94,7 @@ describe('Tram-One', () => {
});
// clear the input
- userEvent.type(getByLabelText(container, 'New Task Label'), '{selectall}{backspace}');
+ userEvent.clear(getByLabelText(container, 'New Task Label'));
// wait for mutation observer to reapply focus
await waitFor(() => {
@@ -111,7 +116,7 @@ describe('Tram-One', () => {
it('should keep focus on the most recent input when components rerender', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously when interacting with an input, if the component would rerender
// focus would be removed from the component and put on the body of the page
@@ -150,7 +155,7 @@ describe('Tram-One', () => {
it('should keep focus when both the parent and child element would update', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously when interacting with an input, if both a parent and child element
// would update, then focus would not reattach, and/or the value would not update correctly
@@ -200,7 +205,7 @@ describe('Tram-One', () => {
it('should not error when resetting focus if the number of elements changed', async () => {
// start the app
- const { container } = startApp();
+ const { container } = await startAppAndWait();
// previously when interacting with an input, if the number of elements decreased
// an error was thrown because the element to focus on no longer existed
@@ -232,11 +237,140 @@ describe('Tram-One', () => {
it('should trigger use-effects of the first resolved element', async () => {
// start the app
- startApp();
+ await startAppAndWait();
// previously, useEffects on the first resolved element would not trigger
// because the effect queue and effect store were pointed to the same object instance
expect(document.title).toEqual('Tram-One Testing App');
});
+
+ it('should keep focus on inputs without a start and end selection', async () => {
+ // start the app
+ const { container } = await startAppAndWait();
+
+ // previously when interacting with an input of a different type (e.g. range)
+ // when reapplying focus Tram-One would throw an error because while the
+ // function for setting selection range exists, it does not work
+
+ // focus on the input (the range input defaults to 0)
+ userEvent.click(getByLabelText(container, 'Store Generator'));
+
+ // verify that the element has focus (before changing the value)
+ await waitFor(() => {
+ expect(getByLabelText(container, 'Store Generator')).toHaveFocus();
+ });
+
+ // change the value of the input
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 1 } });
+
+ // verify the element has the new value
+ expect(getByLabelText(container, 'Store Generator')).toHaveValue('1');
+
+ // wait for mutation observer to re-attach focus
+ // expect the input to keep focus after the change event
+ await waitFor(() => {
+ expect(getByLabelText(container, 'Store Generator')).toHaveFocus();
+ });
+ });
+
+ it('should not reset stores for elements that are still rendered', async () => {
+ // start the app
+ const { container } = await startAppAndWait();
+
+ // previously state would be blown away if a parent element changed state multiple times
+
+ // focus on the input (the range input defaults to 0)
+ userEvent.click(getByLabelText(container, 'Store Generator'));
+
+ // change the value of the input
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 1 } });
+
+ // click on one of the new stores several times
+ userEvent.click(getByText(container, '[0: 0]'));
+ userEvent.click(getByText(container, '[0: 1]'));
+ userEvent.click(getByText(container, '[0: 2]'));
+ userEvent.click(getByText(container, '[0: 3]'));
+ // the button should now say "[0: 4]"
+ expect(getByText(container, '[0: 4]')).toBeVisible();
+
+ // update the number of stores (the parent store element)
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 2 } });
+
+ // wait for mutation observer clean up removed stores
+ await waitFor(() => {
+ // we should see the new buttons
+ expect(getByText(container, '[1: 0]')).toBeVisible();
+ });
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 3 } });
+ // wait for mutation observer clean up removed stores
+ await waitFor(() => {
+ // we should see the new buttons
+ expect(getByText(container, '[2: 0]')).toBeVisible();
+ });
+
+ // we should still see the button with "4,"
+ expect(getByText(container, '[0: 4]')).toBeVisible();
+ });
+
+ it('should reset stores for elements that have been removed', async () => {
+ // start the app
+ const { container } = await startAppAndWait();
+
+ // previously we would hold on to the local state of elements even if they had been removed
+
+ // focus on the input (the range input defaults to 0)
+ userEvent.click(getByLabelText(container, 'Store Generator'));
+
+ // change the value of the input
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 5 } });
+
+ // expect to see all the stores with their initial values
+ await waitFor(() => {
+ expect(getByText(container, '[0: 0]')).toBeVisible();
+ expect(getByText(container, '[1: 0]')).toBeVisible();
+ expect(getByText(container, '[2: 0]')).toBeVisible();
+ expect(getByText(container, '[3: 0]')).toBeVisible();
+ expect(getByText(container, '[4: 0]')).toBeVisible();
+ });
+
+ // click on each of the new stores
+ userEvent.click(getByText(container, '[0: 0]'));
+ userEvent.click(getByText(container, '[1: 0]'));
+ userEvent.click(getByText(container, '[2: 0]'));
+ userEvent.click(getByText(container, '[3: 0]'));
+ userEvent.click(getByText(container, '[4: 0]'));
+
+ // expect to see all the stores with the new values
+ await waitFor(() => {
+ expect(getByText(container, '[0: 1]')).toBeVisible();
+ expect(getByText(container, '[1: 1]')).toBeVisible();
+ expect(getByText(container, '[2: 1]')).toBeVisible();
+ expect(getByText(container, '[3: 1]')).toBeVisible();
+ expect(getByText(container, '[4: 1]')).toBeVisible();
+ });
+
+ // remove all of the stores by setting the value to 0
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 0 } });
+
+ await waitFor(() => {
+ expect(queryByText(container, '[0: 1]')).toBe(null);
+ expect(queryByText(container, '[1: 1]')).toBe(null);
+ expect(queryByText(container, '[2: 1]')).toBe(null);
+ expect(queryByText(container, '[3: 1]')).toBe(null);
+ expect(queryByText(container, '[4: 1]')).toBe(null);
+ });
+
+ // re-add the stores by setting the value to 5
+ fireEvent.change(getByLabelText(container, 'Store Generator'), { target: { value: 5 } });
+
+ // expect to see all the stores with their initial values
+ await waitFor(() => {
+ expect(getByText(container, '[0: 0]')).toBeVisible();
+ expect(getByText(container, '[1: 0]')).toBeVisible();
+ expect(getByText(container, '[2: 0]')).toBeVisible();
+ expect(getByText(container, '[3: 0]')).toBeVisible();
+ expect(getByText(container, '[4: 0]')).toBeVisible();
+ });
+ });
});
diff --git a/integration-tests/test-app/element-store-generator.ts b/integration-tests/test-app/element-store-generator.ts
new file mode 100644
index 0000000..2b63d80
--- /dev/null
+++ b/integration-tests/test-app/element-store-generator.ts
@@ -0,0 +1,34 @@
+import { registerHtml, useStore, TramOneComponent } from '../../src/tram-one';
+import elementwithstore from './element-with-store';
+
+const html = registerHtml({
+ elementwithstore,
+});
+
+/**
+ * Element to verify non-standard input controls, and also verify memory leak type issues
+ */
+const elementstoregenerator: TramOneComponent = () => {
+ const storeGeneratorStore = useStore({ count: 0 });
+ const incrementCount = (event: InputEvent) => {
+ const inputElement = event.target as HTMLInputElement;
+ storeGeneratorStore.count = parseInt(inputElement.value);
+ };
+ const storeElements = [...new Array(storeGeneratorStore.count)].map((_, index) => {
+ return html``;
+ });
+ return html`
+
+
+ ${storeElements}
+ `;
+};
+
+export default elementstoregenerator;
diff --git a/integration-tests/test-app/element-with-store.ts b/integration-tests/test-app/element-with-store.ts
new file mode 100644
index 0000000..b392151
--- /dev/null
+++ b/integration-tests/test-app/element-with-store.ts
@@ -0,0 +1,14 @@
+import { registerHtml, useStore, TramOneComponent } from '../../src/tram-one';
+
+const html = registerHtml();
+
+/**
+ * Dynamicly generated component that could possibly cause memory leaks
+ */
+const elementwithstore: TramOneComponent = ({ index }) => {
+ const subElementStore = useStore({ count: 0 });
+ const onIncrement = () => subElementStore.count++;
+ return html` `;
+};
+
+export default elementwithstore;
diff --git a/integration-tests/test-app/index.html b/integration-tests/test-app/index.html
index 856a987..8aed791 100644
--- a/integration-tests/test-app/index.html
+++ b/integration-tests/test-app/index.html
@@ -3,9 +3,9 @@
diff --git a/integration-tests/test-app/index.ts b/integration-tests/test-app/index.ts
index 3f4c5ed..25ab1be 100644
--- a/integration-tests/test-app/index.ts
+++ b/integration-tests/test-app/index.ts
@@ -8,6 +8,8 @@ import account from './account';
import tasks from './tasks';
import mirrorinput from './mirror-input';
import documentTitleSetter from './document-title-setter';
+import elementstoregenerator from './element-store-generator';
+import { TramWindow } from '../../src/types';
const html = registerHtml({
title: title,
@@ -19,6 +21,7 @@ const html = registerHtml({
tasks: tasks,
'mirror-input': mirrorinput,
'document-title-setter': documentTitleSetter,
+ 'element-store-generator': elementstoregenerator,
});
/**
@@ -47,6 +50,7 @@ export const app = () => {
+
`;
};
@@ -67,6 +71,11 @@ export const startApp = (container: any) => {
window.document.body.appendChild(appContainer);
}
+ // remove all existing state in the tram-space (since the app does not run in an isolated way)
+ Object.keys((window as unknown as TramWindow)['tram-space'] || {}).forEach((globalStore) => {
+ delete (window as unknown as TramWindow)['tram-space'][globalStore];
+ });
+
start(app, appContainer);
return {
diff --git a/integration-tests/test-helpers.ts b/integration-tests/test-helpers.ts
new file mode 100644
index 0000000..f566609
--- /dev/null
+++ b/integration-tests/test-helpers.ts
@@ -0,0 +1,19 @@
+import { TramWindow } from '../src/types';
+
+const { waitFor } = require('@testing-library/dom');
+const { startApp } = require('./test-app');
+
+/**
+ * decorated startApp function that ensures that the app's mutation observers
+ * have kicked in before starting to interact with the app
+ */
+export const startAppAndWait = async () => {
+ const app = startApp();
+
+ await waitFor(() => {
+ // this waitFor is required to have the initial mutation observer trigger
+ expect(Object.keys((window as unknown as TramWindow)['tram-space']['tram-key-store']).length).toBeGreaterThan(0);
+ });
+
+ return app;
+};
diff --git a/integration-tests/warnings.test.js b/integration-tests/warnings.test.js
index 7746768..bdc6d7a 100644
--- a/integration-tests/warnings.test.js
+++ b/integration-tests/warnings.test.js
@@ -8,11 +8,6 @@ const { startApp: startBrokenApp } = require('./broken-app');
*/
describe('Tram-One', () => {
- beforeEach(() => {
- // clean up any tram-one properties between tests
- window['tram-space'] = undefined;
- });
-
it('should warn if selector is not found', () => {
expect(() => startApp('#app')).toThrowError('Tram-One: could not find target, is the element on the page yet?');
});
diff --git a/package-lock.json b/package-lock.json
index d261ca6..ede0f93 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,12 @@
{
"name": "tram-one",
- "version": "11.0.1",
+ "version": "12.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
- "version": "11.0.1",
+ "name": "tram-one",
+ "version": "12.0.0",
"license": "MIT",
"dependencies": {
"@nx-js/observer-util": "^4.2.2",
diff --git a/package.json b/package.json
index ac536e2..3323837 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tram-one",
- "version": "11.0.1",
+ "version": "12.0.0",
"description": "🚋 Modern View Framework for Vanilla Javascript",
"main": "dist/tram-one.cjs",
"commonjs": "dist/tram-one.cjs",
diff --git a/src/dom.ts b/src/dom.ts
index 973aeab..63c9cb8 100644
--- a/src/dom.ts
+++ b/src/dom.ts
@@ -11,7 +11,7 @@ import {
restoreWorkingKey,
} from './working-key';
import observeTag from './observe-tag';
-import processEffects from './process-effects';
+import processHooks from './process-hooks';
import { TRAM_TAG, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS } from './node-names';
import { Registry, Props, DOMTaggedTemplateFunction } from './types';
@@ -49,8 +49,8 @@ export const registerDom = (namespace: string | null, registry: Registry = {}):
};
// observe store usage and process any new effects that were called when building the component
- const processEffectsAndBuildTagResult = () => processEffects(populatedTagFunction);
- const tagResult = observeTag(processEffectsAndBuildTagResult);
+ const processHooksAndBuildTagResult = () => processHooks(populatedTagFunction);
+ const tagResult = observeTag(processHooksAndBuildTagResult);
// pop the branch off (since we are done rendering this component)
popWorkingKeyBranch(TRAM_HOOK_KEY);
diff --git a/src/effect-store.ts b/src/effect-store.ts
index b7bab6c..716e1db 100644
--- a/src/effect-store.ts
+++ b/src/effect-store.ts
@@ -23,8 +23,8 @@ export const {
* clear the effect store
* usually called when we want to empty the effect store
*/
-export const clearEffectStore = (effectName: string) => {
- const effectStore = getEffectStore(effectName);
+export const clearEffectStore = (effectStoreName: string) => {
+ const effectStore = getEffectStore(effectStoreName);
Object.keys(effectStore).forEach((key) => delete effectStore[key]);
};
diff --git a/src/engine-names.ts b/src/engine-names.ts
index 8fca56a..41cddd8 100644
--- a/src/engine-names.ts
+++ b/src/engine-names.ts
@@ -9,5 +9,7 @@
export const TRAM_HOOK_KEY = 'tram-hook-key';
export const TRAM_EFFECT_STORE = 'tram-effect-store';
export const TRAM_EFFECT_QUEUE = 'tram-effect-queue';
+export const TRAM_KEY_STORE = 'tram-key-store';
+export const TRAM_KEY_QUEUE = 'tram-key-queue';
export const TRAM_OBSERVABLE_STORE = 'tram-observable-store';
export const TRAM_MUTATION_OBSERVER = 'tram-mutation-observer';
diff --git a/src/key-queue.ts b/src/key-queue.ts
new file mode 100644
index 0000000..cab61c8
--- /dev/null
+++ b/src/key-queue.ts
@@ -0,0 +1,31 @@
+/*
+ * The KeyQueue in Tram-One is a basic list of keys
+ * that needs to be persisted in the globalSpace.
+ *
+ * Currently this is used with useStore to keep track of what
+ * stores need to be associated with generated elements
+ */
+
+import { buildNamespace } from './namespace';
+
+const newDefaultKeyQueue = () => {
+ return [] as string[];
+};
+
+export const { setup: setupKeyQueue, get: getKeyQueue, set: setKeyQueue } = buildNamespace(newDefaultKeyQueue);
+
+/**
+ * clear the key queue
+ * usually called when we want to empty the key queue
+ */
+export const clearKeyQueue = (keyQueueName: string) => {
+ const keyQueue = getKeyQueue(keyQueueName);
+
+ keyQueue.splice(0, keyQueue.length);
+};
+
+/**
+ * restore the key queue to a previous value
+ * usually used when we had to interrupt the processing of keys
+ */
+export const restoreKeyQueue = setKeyQueue;
diff --git a/src/key-store.ts b/src/key-store.ts
new file mode 100644
index 0000000..de13f78
--- /dev/null
+++ b/src/key-store.ts
@@ -0,0 +1,32 @@
+/*
+ * The KeyStore in Tram-One is a basic key-value object
+ * that needs to be persisted in the globalSpace.
+ *
+ * Currently this is used with useStore and useGlobalStore to keep
+ * track of what stores need to be cleaned up when removing elements
+ */
+
+import { buildNamespace } from './namespace';
+import { KeyObservers } from './types';
+
+const newDefaultKeyStore = () => {
+ return {} as KeyObservers;
+};
+
+export const { setup: setupKeyStore, get: getKeyStore, set: setKeyStore } = buildNamespace(newDefaultKeyStore);
+
+/**
+ * increment (or set initial value) for the keyStore
+ */
+export const incrementKeyStoreValue = (keyStoreName: string, key: string) => {
+ const keyStore = getKeyStore(keyStoreName);
+ keyStore[key] = keyStore[key] + 1 || 1;
+};
+
+/**
+ * decrement a value in the keyStore
+ */
+export const decrementKeyStoreValue = (keyStoreName: string, key: string) => {
+ const keyStore = getKeyStore(keyStoreName);
+ keyStore[key]--;
+};
diff --git a/src/mutation-observer.ts b/src/mutation-observer.ts
index a8b87f9..cdb438b 100644
--- a/src/mutation-observer.ts
+++ b/src/mutation-observer.ts
@@ -8,17 +8,39 @@
const { observe, unobserve } = require('@nx-js/observer-util');
-import { TRAM_TAG, TRAM_TAG_REACTION, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS } from './node-names';
+import {
+ TRAM_TAG,
+ TRAM_TAG_REACTION,
+ TRAM_TAG_NEW_EFFECTS,
+ TRAM_TAG_CLEANUP_EFFECTS,
+ TRAM_TAG_STORE_KEYS,
+} from './node-names';
import { buildNamespace } from './namespace';
import { TramOneElement } from './types';
+import { getObservableStore } from './observable-store';
+import { TRAM_OBSERVABLE_STORE, TRAM_KEY_STORE } from './engine-names';
+import { decrementKeyStoreValue, getKeyStore, incrementKeyStoreValue } from './key-store';
-// process new effects for new nodes
-const processEffects = (node: Node | TramOneElement) => {
- // if this element doesn't have new effects, it is not be a Tram-One Element
- if (!(TRAM_TAG_NEW_EFFECTS in node)) {
+/**
+ * process side-effects for new tram-one nodes
+ * (this includes calling effects, and keeping track of stores)
+ */
+const processTramTags = (node: Node | TramOneElement) => {
+ // if this element doesn't have a TRAM_TAG, it's not a Tram-One Element
+ if (!(TRAM_TAG in node)) {
return;
}
+ const hasStoreKeys = node[TRAM_TAG_STORE_KEYS];
+
+ if (hasStoreKeys) {
+ // for every store associated with this element, increment the count
+ // - this ensures that it doesn't get blown away when we clean up old stores
+ node[TRAM_TAG_STORE_KEYS].forEach((key) => {
+ incrementKeyStoreValue(TRAM_KEY_STORE, key);
+ });
+ }
+
const hasEffects = node[TRAM_TAG_NEW_EFFECTS];
if (hasEffects) {
@@ -51,20 +73,49 @@ const processEffects = (node: Node | TramOneElement) => {
}
};
-// call all cleanup effects on the node
+/**
+ * call all cleanup effects on the node
+ */
const cleanupEffects = (cleanupEffects: (() => void)[]) => {
cleanupEffects.forEach((cleanup) => cleanup());
};
-// unobserve the reaction tied to the node, and run all cleanup effects for the node
+/**
+ * remove the association of the store with this specific element
+ */
+const removeStoreKeyAssociation = (storeKeys: string[]) => {
+ storeKeys.forEach((storeKey) => {
+ decrementKeyStoreValue(TRAM_KEY_STORE, storeKey);
+ });
+};
+
+/**
+ * remove any stores that no longer have any elements associated with them
+ * see removeStoreKeyAssociation above
+ */
+const cleanUpObservableStores = () => {
+ const observableStore = getObservableStore(TRAM_OBSERVABLE_STORE);
+ const keyStore = getKeyStore(TRAM_KEY_STORE);
+ Object.entries(keyStore).forEach(([key, observers]) => {
+ if (observers === 0) {
+ delete observableStore[key];
+ delete keyStore[key];
+ }
+ });
+};
+
+/**
+ * unobserve the reaction tied to the node, and run all cleanup effects for the node
+ */
const clearNode = (node: Node | TramOneElement) => {
- // if this element doesn't have a Reaction, it is not be a Tram-One Element
+ // if this element doesn't have a TRAM_TAG, it's not a Tram-One Element
if (!(TRAM_TAG in node)) {
return;
}
unobserve(node[TRAM_TAG_REACTION]);
cleanupEffects(node[TRAM_TAG_CLEANUP_EFFECTS]);
+ removeStoreKeyAssociation(node[TRAM_TAG_STORE_KEYS]);
};
const isTramOneComponent = (node: Node | TramOneElement) => {
@@ -74,8 +125,9 @@ const isTramOneComponent = (node: Node | TramOneElement) => {
return nodeIsATramOneComponent ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
};
-// function to get the children (as a list) of the node passed in
-// this only needs to query tram-one components, so we can use the attribute `tram`
+/**
+ * function to get the children (as a list) of the node passed in
+ */
const childrenComponents = (node: Node | TramOneElement) => {
const componentWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, isTramOneComponent);
const children = [];
@@ -90,15 +142,20 @@ const mutationObserverNamespaceConstructor = () =>
new MutationObserver((mutationList) => {
// cleanup orphaned nodes that are no longer on the DOM
const removedNodesInMutation = (mutation: MutationRecord) => [...mutation.removedNodes];
- const removedNodes = mutationList.flatMap(removedNodesInMutation).flatMap(childrenComponents);
+ const removedNodes = mutationList.flatMap(removedNodesInMutation);
+ const removedChildNodes = removedNodes.flatMap(childrenComponents);
- removedNodes.forEach(clearNode);
+ removedChildNodes.forEach(clearNode);
// call new effects on any new nodes
const addedNodesInMutation = (mutation: MutationRecord) => [...mutation.addedNodes];
- const newNodes = mutationList.flatMap(addedNodesInMutation).flatMap(childrenComponents);
+ const newNodes = mutationList.flatMap(addedNodesInMutation);
+ const newChildNodes = newNodes.flatMap(childrenComponents);
+
+ newChildNodes.forEach(processTramTags);
- newNodes.forEach(processEffects);
+ // clean up all local observable stores that have no observers
+ cleanUpObservableStores();
});
export const { setup: setupMutationObserver, get: getMutationObserver } = buildNamespace(
diff --git a/src/node-names.ts b/src/node-names.ts
index 1f6a41a..25213bb 100644
--- a/src/node-names.ts
+++ b/src/node-names.ts
@@ -8,5 +8,6 @@
export const TRAM_TAG = 'tram-tag';
export const TRAM_TAG_REACTION = 'tram-tag-reaction';
+export const TRAM_TAG_STORE_KEYS = 'tram-tag-store-keys';
export const TRAM_TAG_NEW_EFFECTS = 'tram-tag-new-effects';
export const TRAM_TAG_CLEANUP_EFFECTS = 'tram-tag-cleanup-effects';
diff --git a/src/observable-hook.ts b/src/observable-hook.ts
index 41cf6c1..0de4583 100644
--- a/src/observable-hook.ts
+++ b/src/observable-hook.ts
@@ -1,8 +1,9 @@
-import { TRAM_OBSERVABLE_STORE, TRAM_HOOK_KEY } from './engine-names';
+import { TRAM_OBSERVABLE_STORE, TRAM_HOOK_KEY, TRAM_KEY_QUEUE } from './engine-names';
import { getObservableStore } from './observable-store';
import { getWorkingKeyValue, incrementWorkingKeyBranch } from './working-key';
import { StoreObject } from './types';
+import { getKeyQueue } from './key-queue';
/**
* Shared source code for both observable hooks, useStore, and useGlobalStore.
@@ -16,7 +17,7 @@ export default (key?: string, value?: Store): Store =
const observableStore = getObservableStore(TRAM_OBSERVABLE_STORE);
// increment the working key branch value
- // this makes successive useEffects calls unique (until we reset the key)
+ // this makes successive hooks unique (until we reset the key)
incrementWorkingKeyBranch(TRAM_HOOK_KEY);
// if a key was passed in, use that, otherwise, generate a key
@@ -32,6 +33,13 @@ export default (key?: string, value?: Store): Store =
// get value for key
const keyValue = observableStore[resolvedKey];
+ // if we weren't passed in a key, this is a local obserable (not global),
+ const isLocalStore = !key;
+ if (isLocalStore) {
+ // if this is local, we should associate it with the element by putting it in the keyQueue
+ getKeyQueue(TRAM_KEY_QUEUE).push(resolvedKey);
+ }
+
// return value
return keyValue;
};
diff --git a/src/observe-tag.ts b/src/observe-tag.ts
index bf3e6a0..cef5776 100644
--- a/src/observe-tag.ts
+++ b/src/observe-tag.ts
@@ -1,6 +1,6 @@
const { observe } = require('@nx-js/observer-util');
-import { TRAM_TAG_REACTION, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS } from './node-names';
+import { TRAM_TAG_REACTION, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS, TRAM_TAG } from './node-names';
import { TramOneElement, RemovedElementDataStore, Reaction, ElementPotentiallyWithSelectionAndFocus } from './types';
// functions to go to nodes or indices (made for .map)
@@ -120,18 +120,27 @@ export default (tagFunction: () => TramOneElement): TramOneElement => {
elementToGiveFocus = allActiveLikeElements[elementIndexToGiveFocus] as ElementPotentiallyWithSelectionAndFocus;
// also try to set the selection, if there is a selection for this element
- if (elementToGiveFocus.setSelectionRange !== undefined) {
- elementToGiveFocus.setSelectionRange(
- removedElementWithFocusData.selectionStart,
- removedElementWithFocusData.selectionEnd,
- removedElementWithFocusData.selectionDirection
- );
+ try {
+ if (elementToGiveFocus.setSelectionRange !== undefined) {
+ elementToGiveFocus.setSelectionRange(
+ removedElementWithFocusData.selectionStart,
+ removedElementWithFocusData.selectionEnd,
+ removedElementWithFocusData.selectionDirection
+ );
+ }
+ } catch (exception) {
+ // don't worry if we fail
+ // this can happen if the element has a `setSelectionRange` but it isn't supported
+ // e.g. input with type="range"
}
elementToGiveFocus.scrollLeft = removedElementWithFocusData.scrollLeft;
elementToGiveFocus.scrollTop = removedElementWithFocusData.scrollTop;
}
+ // don't lose track that this is still a tram-one element
+ tagResult[TRAM_TAG] = true;
+
// copy the reaction and effects from the old tag to the new one
tagResult[TRAM_TAG_REACTION] = oldTag[TRAM_TAG_REACTION];
tagResult[TRAM_TAG_NEW_EFFECTS] = oldTag[TRAM_TAG_NEW_EFFECTS];
diff --git a/src/process-effects.ts b/src/process-hooks.ts
similarity index 53%
rename from src/process-effects.ts
rename to src/process-hooks.ts
index 841f916..25afc85 100644
--- a/src/process-effects.ts
+++ b/src/process-hooks.ts
@@ -1,20 +1,25 @@
-import { TRAM_EFFECT_STORE, TRAM_EFFECT_QUEUE } from './engine-names';
-import { TRAM_TAG_NEW_EFFECTS } from './node-names';
+import { TRAM_EFFECT_STORE, TRAM_EFFECT_QUEUE, TRAM_KEY_QUEUE } from './engine-names';
+import { TRAM_TAG_NEW_EFFECTS, TRAM_TAG_STORE_KEYS } from './node-names';
import { getEffectStore, clearEffectStore, restoreEffectStore } from './effect-store';
import { TramOneElement } from './types';
+import { clearKeyQueue, getKeyQueue, restoreKeyQueue } from './key-queue';
/**
* This is a helper function for the dom creation.
- * This function stores any effects generated when building a tag in resulting node that is generated.
+ * This function stores any keys generated when building a tag in the resulting node that is generated.
*
* These are later processed by the mutation-observer, and cleaned up when the node is removed by the mutation-observer.
+ *
+ * This function is called every time state changes in an observable store
*/
export default (tagFunction: () => TramOneElement) => {
- // save the existing effect queue for any components we are in the middle of building
+ // save the existing effect queue and key queue for any components we are in the middle of building
const existingQueuedEffects = { ...getEffectStore(TRAM_EFFECT_QUEUE) };
+ const existingQueuedKeys = [...getKeyQueue(TRAM_KEY_QUEUE)];
- // clear the effect queue (so we can listen for just new effects)
+ // clear the queues (so we can get just new effects and keys)
clearEffectStore(TRAM_EFFECT_QUEUE);
+ clearKeyQueue(TRAM_KEY_QUEUE);
// create the component, which will save new effects to the effect queue
const tagResult = tagFunction();
@@ -23,12 +28,19 @@ export default (tagFunction: () => TramOneElement) => {
const existingEffects = getEffectStore(TRAM_EFFECT_STORE);
const queuedEffects = getEffectStore(TRAM_EFFECT_QUEUE);
+ // get all new keys
+ const newKeys = getKeyQueue(TRAM_KEY_QUEUE);
+
// store new effects in the node we just built
const newEffects = Object.keys(queuedEffects).filter((effect) => !(effect in existingEffects));
tagResult[TRAM_TAG_NEW_EFFECTS] = newEffects.map((newEffectKey) => queuedEffects[newEffectKey]);
- // restore the effect queue to what it was before we started
+ // store keys in the node we just built
+ tagResult[TRAM_TAG_STORE_KEYS] = newKeys;
+
+ // restore the effect and key queues to what they were before we started
restoreEffectStore(TRAM_EFFECT_QUEUE, existingQueuedEffects);
+ restoreKeyQueue(TRAM_KEY_QUEUE, existingQueuedKeys);
return tagResult;
};
diff --git a/src/start.ts b/src/start.ts
index 4a1604b..5726dd8 100644
--- a/src/start.ts
+++ b/src/start.ts
@@ -6,6 +6,8 @@ import {
TRAM_EFFECT_QUEUE,
TRAM_OBSERVABLE_STORE,
TRAM_MUTATION_OBSERVER,
+ TRAM_KEY_QUEUE,
+ TRAM_KEY_STORE,
} from './engine-names';
import { setupTramOneSpace } from './namespace';
import { setupEffectStore } from './effect-store';
@@ -13,6 +15,8 @@ import { setupWorkingKey } from './working-key';
import { setupObservableStore } from './observable-store';
import { setupMutationObserver, startWatcher } from './mutation-observer';
import { ElementOrSelector, TramOneComponent } from './types';
+import { setupKeyQueue } from './key-queue';
+import { setupKeyStore } from './key-store';
/**
* @name start
@@ -32,6 +36,9 @@ export default (component: TramOneComponent, target: ElementOrSelector) => {
// get the container to mount the app on
const container = buildContainer(target);
+ // setup the window object to hold stores and queues
+ // in the future, we may allow this to be customized
+ // for multiple, sandboxed, instances of Tram-One
setupTramOneSpace();
// setup store for effects
@@ -46,6 +53,12 @@ export default (component: TramOneComponent, target: ElementOrSelector) => {
// setup observable store for the useStore and useGlobalStore hooks
setupObservableStore(TRAM_OBSERVABLE_STORE);
+ // setup key store for keeping track of stores to clean up
+ setupKeyStore(TRAM_KEY_STORE);
+
+ // setup key queue for new observable stores when resolving mounts
+ setupKeyQueue(TRAM_KEY_QUEUE);
+
// setup a mutation observer for cleaning up removed elements and triggering effects
setupMutationObserver(TRAM_MUTATION_OBSERVER);
diff --git a/src/types.ts b/src/types.ts
index afd7789..0ad1b93 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,10 @@
-import { TRAM_TAG, TRAM_TAG_REACTION, TRAM_TAG_NEW_EFFECTS, TRAM_TAG_CLEANUP_EFFECTS } from './node-names';
+import {
+ TRAM_TAG,
+ TRAM_TAG_REACTION,
+ TRAM_TAG_NEW_EFFECTS,
+ TRAM_TAG_CLEANUP_EFFECTS,
+ TRAM_TAG_STORE_KEYS,
+} from './node-names';
/* ============= PUBLIC TYPES ========================================
* A lot of the types here are wrapped using an array / index of 0.
@@ -88,6 +94,7 @@ export interface TramOneElement extends Element {
[TRAM_TAG_REACTION]: Reaction;
[TRAM_TAG_NEW_EFFECTS]: Effect[];
[TRAM_TAG_CLEANUP_EFFECTS]: CleanupEffect[];
+ [TRAM_TAG_STORE_KEYS]: string[];
}
/* ============= INTERNAL TYPES ========================================
@@ -158,3 +165,10 @@ export interface ElementPotentiallyWithSelectionAndFocus extends Element {
export interface EffectStore {
[callLikeKey: string]: Effect;
}
+
+/**
+ * Type for keeping track of the number of observers for a store
+ */
+export type KeyObservers = {
+ [key: string]: number;
+};
diff --git a/src/working-key.ts b/src/working-key.ts
index 71c3e26..be89d5e 100644
--- a/src/working-key.ts
+++ b/src/working-key.ts
@@ -8,16 +8,17 @@ import { WorkingkeyObject } from './types';
* values or effects to pull / trigger.
*/
-const defaultWorkingKey = {
- // list of custom tags that we've stepped into
- branch: [],
- // map of branches to index value (used as a cursor for hooks)
- branchIndices: {
- '': 0,
- },
-} as WorkingkeyObject;
+const defaultWorkingKey = () =>
+ ({
+ // list of custom tags that we've stepped into
+ branch: [],
+ // map of branches to index value (used as a cursor for hooks)
+ branchIndices: {
+ '': 0,
+ },
+ } as WorkingkeyObject);
-export const { setup: setupWorkingKey, get: getWorkingKey } = buildNamespace(() => defaultWorkingKey);
+export const { setup: setupWorkingKey, get: getWorkingKey } = buildNamespace(defaultWorkingKey);
const getWorkingBranch = (keyName: string) => {
const workingkeyObject = getWorkingKey(keyName);