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);