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
complete edit archive \n' +
- '\t\t\t\t\t\t\t\t\n' +
+ '\t\t\t\t\t\t\t
complete edit \n' +
+ '\t\t\t\t\t\t\t\tarchive \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
edit archive \n' +
+ '\t\t\t\t\t\t\t
archive \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)
+ }
+ }
+ })
+ }
+ )
+ }
+ )
+ }
}
/**