diff --git a/docs-src/config.ts b/docs-src/config.ts
index 7096b2fe..764e8f7c 100644
--- a/docs-src/config.ts
+++ b/docs-src/config.ts
@@ -25,6 +25,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 =
@@ -150,6 +151,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/package.json b/package.json
index a1c3c917..1963a853 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@beforesemicolon/markup",
- "version": "0.13.3",
+ "version": "0.14.1",
"description": "Reactive HTML Templating System",
"engines": {
"node": ">=18.16.0"
diff --git a/src/executable/handle-executable.ts b/src/executable/handle-executable.ts
index 330c6ef5..66936d49 100644
--- a/src/executable/handle-executable.ts
+++ b/src/executable/handle-executable.ts
@@ -146,11 +146,12 @@ 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
@@ -182,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..57601742 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,87 @@ describe('html', () => {
expect(document.body.innerHTML).toBe('
1 + ')
});
- it('should handle onUpdate callback', () => {
- const [count, setCount] = state
(0)
- const updateMock = jest.fn()
+ describe('should handle lifecycles', () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
- const counter = html`${count} `
- counter.onUpdate(updateMock)
- counter.render(document.body)
+ 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)
+
+ jest.advanceTimersByTime(100);
+
+ expect(updateMock).toHaveBeenCalledTimes(1)
+
+ expect(document.body.innerHTML).toBe('1 ')
+ })
- expect(document.body.innerHTML).toBe('0 ')
+ it('onMount', () => {
- setCount((prev) => prev + 1)
+ const mountMock = jest.fn()
+
+ html`sample `
+ .onMount(mountMock)
+ .render(document.body)
+
+ jest.advanceTimersByTime(100);
+
+ 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();
+
+ jest.advanceTimersByTime(100);
+
+ 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')
+
+ jest.advanceTimersByTime(100);
+
+ 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 a50dbc6b..204f1bad 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.#broadcast(this.#mountSubs)
}
+
+ 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.#broadcast(this.#mountSubs)
// 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,19 +194,19 @@ export class HtmlTemplate {
this.#executablesByNode.forEach((executable, node) => {
handleExecutable(node, executable, this.#refs)
})
- this.#subs.forEach((cb) => cb())
+ this.#broadcast(this.#updateSubs)
}
}
unmount() {
- this.nodes.forEach((n) => {
+ this.#nodes.forEach((n) => {
if (n.parentNode) {
n.parentNode.removeChild(n)
}
})
this.#renderTarget = null
- this.#root = null
this.unsubscribeFromStates()
+ this.#broadcast(this.#unmountSubs)
}
unsubscribeFromStates = () => {
@@ -204,21 +216,32 @@ export class HtmlTemplate {
this.#stateUnsubs.clear()
}
- onUpdate(cb: () => () => void) {
- if (typeof cb === 'function') {
- this.#subs.add(cb)
+ onUpdate(cb: () => void) {
+ return this.#sub(cb, this.#updateSubs)
+ }
- return () => {
- this.#subs.delete(cb)
- }
+ onMount(cb: () => void) {
+ return this.#sub(cb, this.#mountSubs)
+ }
+
+ onUnmount(cb: () => void) {
+ return this.#sub(cb, this.#unmountSubs)
+ }
+
+ #sub(cb: () => void, set: Set<() => void>) {
+ if (typeof cb === 'function') {
+ set.add(cb)
}
+
+ return this
+ }
+
+ #broadcast(set: Set<() => void>) {
+ set.forEach((sub) => setTimeout(sub, 0))
}
#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
@@ -232,40 +255,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)
+ }
+ }
+ })
+ }
+ )
+ }
+ )
+ }
}
/**