From c9565b52ca947f3d08306c7d275a013b9eeaa2a2 Mon Sep 17 00:00:00 2001 From: Elson Correia <13211046+ECorreia45@users.noreply.github.com> Date: Sat, 16 Dec 2023 21:27:36 -0500 Subject: [PATCH] introduce onMount and onUnmount (#9) * introduce onMount and onUnmount * template lifecycle doc --- docs-src/config.ts | 10 ++ .../creating-and-rendering.page.ts | 13 -- .../documentation/template-lifecycles.page.ts | 142 ++++++++++++++++ src/executable/handle-executable.ts | 40 ++--- src/html.spec.ts | 98 ++++++++--- src/html.ts | 152 +++++++++++------- 6 files changed, 346 insertions(+), 109 deletions(-) create mode 100644 docs-src/documentation/template-lifecycles.page.ts diff --git a/docs-src/config.ts b/docs-src/config.ts index 192d7245..65d3bdbf 100644 --- a/docs-src/config.ts +++ b/docs-src/config.ts @@ -28,6 +28,7 @@ import FunctionComponentsPage from './documentation/function-components.page' import WebComponentsPage from './documentation/web-components.page' import ServerSideRenderingPage from './documentation/server-side-rendering.page' import StateStorePage from './documentation/state-store.page' +import TemplateLifecyclesPage from './documentation/template-lifecycles.page' import { DocumentsGroup, Page } from './type' const genericDescription = @@ -153,6 +154,15 @@ const config: { name: string; pages: Page[] } = { group: 'Templating', root: false, }, + { + path: '/documentation/template-lifecycles', + name: 'Template Lifecycles', + title: 'Documentation: Template Lifecycles', + description: genericDescription, + component: TemplateLifecyclesPage, + group: 'Templating', + root: false, + }, { path: '/documentation/what-is-a-helper', name: 'What is a Helper?', diff --git a/docs-src/documentation/creating-and-rendering.page.ts b/docs-src/documentation/creating-and-rendering.page.ts index 3fe62019..8d85e888 100644 --- a/docs-src/documentation/creating-and-rendering.page.ts +++ b/docs-src/documentation/creating-and-rendering.page.ts @@ -108,18 +108,5 @@ export default ({ render it in a different place, it will be automatically removed from the previous place.

- ${Heading('Unmounting', 'h3')} -

- Another method you have available is the - unmount which gives you the ability to unmount your - template the right way. -

- ${CodeSnippet('temp.unmount()', 'typescript')} -

- The unmount method will unsubscribe from any - state and reset the template - instance to its original state ready to be re-rendered by - calling the render method. -

`, }) diff --git a/docs-src/documentation/template-lifecycles.page.ts b/docs-src/documentation/template-lifecycles.page.ts new file mode 100644 index 00000000..cb4f063b --- /dev/null +++ b/docs-src/documentation/template-lifecycles.page.ts @@ -0,0 +1,142 @@ +import { html } from '../../src' +import { DocPageLayout } from '../partials/doc-page-layout' +import { Heading } from '../partials/heading' +import { PageComponentProps } from '../type' +import { CodeSnippet } from '../partials/code-snippet' + +export default ({ + name, + page, + nextPage, + prevPage, + docsMenu, +}: PageComponentProps) => + DocPageLayout({ + name, + page, + prevPage, + nextPage, + docsMenu, + content: html` + ${Heading(page.name)} +

+ Markup templates offer a convenient way to tap into its + lifecycles, so you can perform setup and teardown actions. +

+ ${Heading('onMount', 'h3')} +

+ The onMount lifecycle allows you to react to when + the template is rendered. This is triggered whenever the + render and replace + methods successfully render the nodes in the provided target. +

+ ${CodeSnippet( + 'const temp = html`\n' + + '

sample

\n' + + '`;\n' + + '\n' + + 'temp.onMount(() => {\n' + + ' // handle mount\n' + + '})\n' + + '\n' + + 'temp.render(document.body)', + 'typescript' + )} +

+ You can always check if the place the template was rendered is + in the DOM by checking the isConnected on the + renderTarget. +

+ ${CodeSnippet('temp.renderTarget.isConnected;', 'typescript')} + ${Heading('onUnmount', 'h3')} +

+ The onUnmount lifecycle allows you to react to when + the template is removed from the element it was rendered. This + is triggered whenever the unmount method + successfully unmounts the template. +

+ ${CodeSnippet( + 'const temp = html`\n' + + '

sample

\n' + + '`;\n' + + '\n' + + 'temp.onUnmount(() => {\n' + + ' // handle unmount\n' + + '})\n' + + '\n' + + 'temp.render(document.body)', + 'typescript' + )} +

+ You can call the unmount method directly in the + code but Markup also tracks templates behind the scenes + individually. +

+

+ Whenever templates are no longer needed, the + unmount method is called to remove them. Thus, all + the cleanup for the template is performed. +

+ ${Heading('onUpdate', 'h3')} +

+ The onUpdate lifecycle allows you to react to when + an update is requested for the template. This can be by calling + the update method or automatically is you are using + state. +

+ ${CodeSnippet( + 'const [count, setCount] = state(0);\n\n' + + 'const temp = html`\n' + + '

${count}

\n' + + '`;\n' + + '\n' + + 'temp.onUpdate(() => {\n' + + ' // handle update\n' + + '})\n' + + '\n' + + 'temp.render(document.body)', + 'typescript' + )} + ${Heading('Chainable methods', 'h3')} +

+ Markup allows you to chain the following methods: + render, replace, onMount, + onUnmount, and onUpdate. +

+ ${CodeSnippet( + 'html`

sample

`\n' + + ' .onMount(() => {\n' + + ' // handle mount\n' + + ' })\n' + + ' .onUnmount(() => {\n' + + ' // handle unmount\n' + + ' })\n' + + ' .onUpdate(() => {\n' + + ' // handle update\n' + + ' })\n' + + ' .render(document.body)', + 'typescript' + )} +

+ This makes it easy to handle things in a function where you need + to return the template. +

+ ${CodeSnippet( + 'const Button = ({content, type, disabled}) => {\n' + + ' \n' + + ' return html`\n' + + ' \n' + + ' ${content}\n' + + ' \n' + + ' `\n' + + ' .onUpdate(() => {\n' + + ' // handle update\n' + + ' })\n' + + '}', + 'typescript' + )} + `, + }) diff --git a/src/executable/handle-executable.ts b/src/executable/handle-executable.ts index 21b10e89..66936d49 100644 --- a/src/executable/handle-executable.ts +++ b/src/executable/handle-executable.ts @@ -146,26 +146,21 @@ export function handleTextExecutableValue( refs: Record>, el: Node ) { - const value = partsToValue(val.parts) + const value = partsToValue(val.parts) as Array const nodes: Array = [] - let idx = 0 - for (const v of value as Array) { + for (let i = 0; i < value.length; i++) { + const v = value[i] + if (v instanceof HtmlTemplate) { const renderedBefore = v.renderTarget !== null - if (!renderedBefore) { - v.render(document.createElement('div')) - // need to disconnect these nodes because the div created above - // is only used ,so we can get access to the nodes but not necessarily - // where we want these nodes to be rendered at - v.nodes.forEach((node) => { - node.parentNode?.removeChild(node) - }) - } else { + if (renderedBefore) { // could be that the component was sitting around while data changed // for that we need to update it, so it has the latest data v.update() + } else { + v.render(document.createElement('div')) } // collect dynamic refs that could appear @@ -188,20 +183,29 @@ export function handleTextExecutableValue( // to avoid unnecessary DOM updates if ( Array.isArray(val.value) && - String(val.value[idx]) === String(v) + String(val.value[i]) === String(v) ) { - nodes.push(val.renderedNodes[idx]) + nodes.push(val.renderedNodes[i]) } else { nodes.push(document.createTextNode(String(v))) } } - - idx += 1 } - val.value = value - // need to make sure nodes array does not have repeated nodes // which cannot be rendered in 2 places at once handleTextExecutable(val, Array.from(new Set(nodes)), el) + + // clean up templates removed by unmounting them + if (Array.isArray(val.value)) { + const valueSet = new Set(value) + + for (const v of val.value as unknown[]) { + if (v instanceof HtmlTemplate && !valueSet.has(v)) { + v.unmount() + } + } + } + + val.value = value } diff --git a/src/html.spec.ts b/src/html.spec.ts index d600cdcb..dee549f1 100644 --- a/src/html.spec.ts +++ b/src/html.spec.ts @@ -1,5 +1,5 @@ import {html, HtmlTemplate, state} from './html' -import {when, repeat} from './helpers' +import {when, repeat, oneOf} from './helpers' import {suspense} from './utils' import {helper} from "./Helper"; @@ -1269,15 +1269,15 @@ describe('html', () => {
${when( () => this.#status === 'pending', - html`${completeBtn}${editBtn}${archiveBtn}` + html`${completeBtn}${editBtn}` )} ${when( - () => this.#status === 'archived', - html`${progressBtn}${deleteBtn}` + oneOf(this.#status, ['completed', 'pending']), + archiveBtn )} ${when( - () => this.#status === 'completed', - archiveBtn + () => this.#status === 'archived', + html`${progressBtn}${deleteBtn}` )}
` @@ -1336,8 +1336,8 @@ describe('html', () => { expect(todo.shadowRoot?.innerHTML).toBe('
\n' + '\t\t\t\t\t\t\t
\n' + '\t\t\t\t\t\t\t\t

sample

\n' + - '\t\t\t\t\t\t\t
\n' + - '\t\t\t\t\t\t\t\t\n' + + '\t\t\t\t\t\t\t
\n' + + '\t\t\t\t\t\t\t\t\n' + '\t\t\t\t\t\t\t\t
\n' + '\t\t\t\t\t\t
') @@ -1348,11 +1348,10 @@ describe('html', () => { expect(document.body.innerHTML).toBe( '' ) - expect(todo.shadowRoot?.innerHTML).toBe( - '
\n' + + expect(todo.shadowRoot?.innerHTML).toBe('
\n' + '\t\t\t\t\t\t\t
\n' + '\t\t\t\t\t\t\t\t

sample

\n' + - '\t\t\t\t\t\t\t
\n' + + '\t\t\t\t\t\t\t
\n' + '\t\t\t\t\t\t\t\t
\n' + '\t\t\t\t\t\t
' ) @@ -1636,21 +1635,74 @@ describe('html', () => { expect(document.body.innerHTML).toBe('1') }); - it('should handle onUpdate callback', () => { - const [count, setCount] = state(0) - const updateMock = jest.fn() - - const counter = html`${count}` - counter.onUpdate(updateMock) - counter.render(document.body) - - expect(document.body.innerHTML).toBe('0') + describe('should handle lifecycles', () => { + it('onUpdate', () => { + const [count, setCount] = state(0) + const updateMock = jest.fn() + + const counter = html`${count}` + counter.onUpdate(updateMock) + counter.render(document.body) + + expect(document.body.innerHTML).toBe('0') + + setCount((prev) => prev + 1) + + expect(updateMock).toHaveBeenCalledTimes(1) + + expect(document.body.innerHTML).toBe('1') + }) - setCount((prev) => prev + 1) + it('onMount', () => { + const mountMock = jest.fn() + + html`sample` + .onMount(mountMock) + .render(document.body) + + expect(mountMock).toHaveBeenCalledTimes(1) + }); - expect(updateMock).toHaveBeenCalledTimes(1) + it('onUnmount', () => { + const unmountMock = jest.fn() + + const temp = html`sample` + .onUnmount(unmountMock) + .render(document.body) + + temp.unmount(); + + expect(unmountMock).toHaveBeenCalledTimes(1) + }); - expect(document.body.innerHTML).toBe('1') + it('onUnmount on removed item', () => { + const unmountMock = jest.fn(); + const list = [ + html`one`.onUnmount(unmountMock), + html`two`.onUnmount(unmountMock), + html`three`.onUnmount(unmountMock), + ] + + const temp = html`${() => list}` + .render(document.body) + + expect(document.body.innerHTML).toBe('onetwothree') + + list.splice(1, 1); + const three = list.splice(1, 1); + + temp.update(); + + expect(document.body.innerHTML).toBe('one') + + expect(unmountMock).toHaveBeenCalledTimes(2) + + list.unshift(...three); + + temp.update(); + + expect(document.body.innerHTML).toBe('threeone') + }); }) it('should ignore values between tag and attribute', () => { diff --git a/src/html.ts b/src/html.ts index 60e40a44..2ebd8c59 100644 --- a/src/html.ts +++ b/src/html.ts @@ -19,7 +19,9 @@ export class HtmlTemplate { #nodes: Node[] = [] #renderTarget: ShadowRoot | Element | null = null #refs: Record> = {} - #subs: Set<() => () => void> = new Set() + #updateSubs: Set<() => void> = new Set() + #mountSubs: Set<() => void> = new Set() + #unmountSubs: Set<() => void> = new Set() #stateUnsubs: Set<() => void> = new Set() #executablesByNode: Map = new Map() #values: Array = [] @@ -114,14 +116,20 @@ export class HtmlTemplate { if (!this.#root) { this.#init(elementToAttachNodesTo as Element) + } else if (!this.#stateUnsubs.size) { + this.#subscribeToState() + this.update() } - this.nodes.forEach((node) => { + this.#nodes.forEach((node) => { if (node.parentNode !== elementToAttachNodesTo) { elementToAttachNodesTo.appendChild(node) } }) + this.#mountSubs.forEach((sub) => sub()) } + + return this } /** @@ -150,13 +158,19 @@ export class HtmlTemplate { return } + this.#renderTarget = element.parentNode as Element + if (!this.#root) { this.#init(element) + } else if (!this.#stateUnsubs.size) { + this.#subscribeToState() + this.update() } const frag = document.createDocumentFragment() - frag.append(...this.nodes) + frag.append(...this.#nodes) element.parentNode?.replaceChild(frag, element) + this.#mountSubs.forEach((sub) => sub()) // only need to unmount the template nodes // if the target is not a template, it will be automatically removed @@ -165,9 +179,7 @@ export class HtmlTemplate { target.unmount() } - this.#renderTarget = element.parentNode as Element - - return + return this } throw new Error(`Invalid replace target element. Received ${target}`) @@ -182,25 +194,27 @@ export class HtmlTemplate { this.#executablesByNode.forEach((executable, node) => { handleExecutable(node, executable, this.#refs) }) - this.#subs.forEach((cb) => cb()) + this.#updateSubs.forEach((cb) => cb()) } } unmount() { - // make sure the inner HTML template also reset - this.#values.forEach((val) => { - if (val instanceof HtmlTemplate) { - val.unmount() - } - }) - this.nodes.forEach((n) => { - if (n.parentNode) { - n.parentNode.removeChild(n) - } - }) - this.#renderTarget = null - this.#root = null - this.unsubscribeFromStates() + if (this.#renderTarget) { + // make sure the inner HTML template also reset + this.#values.forEach((val) => { + if (val instanceof HtmlTemplate) { + val.unmount() + } + }) + this.#nodes.forEach((n) => { + if (n.parentNode) { + n.parentNode.removeChild(n) + } + }) + this.#renderTarget = null + this.unsubscribeFromStates() + this.#unmountSubs.forEach((sub) => sub()) + } } unsubscribeFromStates = () => { @@ -210,21 +224,32 @@ export class HtmlTemplate { this.#stateUnsubs.clear() } - onUpdate(cb: () => () => void) { + onUpdate(cb: () => void) { if (typeof cb === 'function') { - this.#subs.add(cb) + this.#updateSubs.add(cb) + } - return () => { - this.#subs.delete(cb) - } + return this + } + + onMount(cb: () => void) { + if (typeof cb === 'function') { + this.#mountSubs.add(cb) } + + return this + } + + onUnmount(cb: () => void) { + if (typeof cb === 'function') { + this.#unmountSubs.add(cb) + } + + return this } #init(target: ShadowRoot | Element) { if (target) { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this - this.#root = parse( this.#htmlTemplate, // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -238,40 +263,57 @@ export class HtmlTemplate { attributes: [], }) } - - // subscribe to any state value used in the node - e.parts.forEach(function sub(val: unknown) { - if (val instanceof Helper) { - val.args.forEach(sub) - } else if ( - typeof val === 'function' && - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - typeof val[id] === 'function' - ) { - const nodeExec = self.#executablesByNode.get(node) - - if (nodeExec) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const unsub = val[id](() => { - handleExecutable(node, nodeExec, self.#refs) - self.#subs.forEach((cb) => cb()) - }, id) - self.#stateUnsubs.add(unsub) - } - } - }) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore this.#executablesByNode.get(node)[type].push(e) }) ) as DocumentFragment - + this.#subscribeToState() this.#nodes = Array.from(this.#root.childNodes) } } + + #subscribeToState() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this + + Array.from(this.#executablesByNode.entries()).forEach( + ([node, { content, directives, attributes, events }]) => { + ;[...content, ...directives, ...events, ...attributes].forEach( + (e) => { + // subscribe to any state value used in the node + e.parts.forEach(function sub(val: unknown) { + if (val instanceof Helper) { + val.args.forEach(sub) + } else if ( + typeof val === 'function' && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + typeof val[id] === 'function' + ) { + const nodeExec = + self.#executablesByNode.get(node) + + if (nodeExec) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const unsub = val[id](() => { + handleExecutable( + node, + nodeExec, + self.#refs + ) + self.#updateSubs.forEach((cb) => cb()) + }, id) + self.#stateUnsubs.add(unsub) + } + } + }) + } + ) + } + ) + } } /**