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
\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,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) + } + } + }) + } + ) + } + ) + } } /**