import { assert, is_html, is_iterable, is_keyed, is_renderable, single_part_template, type Displayable, type Key, type Renderable, } from '../shared.ts' import { compile_template, PART_ATTRIBUTE, PART_CHILD, PART_DIRECTIVE, PART_PROPERTY, type CompiledTemplate, } from './compiler.ts' import { controllers, get_controller } from './controller.ts' import { create_span_after, delete_contents, extract_contents, insert_node, type Span } from './span.ts' import type { Cleanup } from './util.ts' export type Part = (value: unknown) => void export function create_child_part( span: Span, // for when we're rendering a renderable: needs_revalidate = true, current_renderable?: Renderable, // for when we're rendering a template: old_template?: CompiledTemplate, template_parts?: [number, Part][], // for when we're rendering multiple values: entries?: Array<{ _span: Span; _part: Part; _key: Key }>, // for when we're rendering a string/single dom node: // undefined means no previous value, because a user-specified undefined is remapped to null old_value?: unknown, ): Part { function switch_renderable(next: Renderable | undefined) { if (current_renderable && current_renderable !== next) { const controller = controllers.get(current_renderable) if (controller) { controller._invalidate.delete(switch_renderable) // If this was the last instance, call unmount callbacks if (!controller._invalidate.size) { controller._unmount_callbacks.forEach(callback => callback?.()) controller._unmount_callbacks.length = 0 } } } current_renderable = next } function disconnect_root() { if (template_parts !== undefined) { for (const [, part] of template_parts) part(null) old_template = undefined template_parts = undefined } } return function update(value) { if (is_renderable(value)) { if (!needs_revalidate && value === current_renderable) return needs_revalidate = false switch_renderable(value) const renderable = value const controller = get_controller(renderable) // If this is the first mounted instance, call mount callbacks if (!controller._invalidate.size) { controller._unmount_callbacks = controller._mount_callbacks.map(callback => callback()) } controller._invalidate.set(switch_renderable, () => { assert(renderable === current_renderable) needs_revalidate = true update(renderable) }) try { value = renderable.render() } catch (thrown) { if (is_html(thrown)) { value = thrown } else { throw thrown } } // if render returned another renderable, we want to track/cache both renderables individually. // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables. if (is_renderable(value)) value = single_part_template(value) } else switch_renderable(undefined) // if it's undefined, swap the value for null. // this means if the initial value is undefined, // it won't conflict with old_value's default of undefined, // so it'll still render. if (value === undefined) value = null // NOTE: we're explicitly not caching/diffing the value when it's an iterable, // given it can yield different values but have the same identity. (e.g. arrays) if (is_iterable(value)) { if (!entries) { // we previously rendered a single value, so we need to clear it. disconnect_root() delete_contents(span) entries = [] } // create or update a root for every item. let i = 0 let end = span._start for (const item of value) { const key = is_keyed(item) ? item._key : (item as Key) if (entries.length <= i) { const span = create_span_after(end) entries[i] = { _span: span, _part: create_child_part(span), _key: key } } if (key !== undefined && entries[i]._key !== key) { for (let j = i + 1; j < entries.length; j++) { const entry1 = entries[i] const entry2 = entries[j] if (entry2._key === key) { // swap the contents of the spans const tmp_content = extract_contents(entry1._span) insert_node(entry1._span, extract_contents(entry2._span)) insert_node(entry2._span, tmp_content) // swap the spans back const tmp_span = { ...entry1._span } Object.assign(entry1._span, entry2._span) Object.assign(entry2._span, tmp_span) // swap the roots entries[j] = entry1 entries[i] = entry2 break } } entries[i]._key = key } entries[i]._part(item as Displayable) end = entries[i]._span._end i++ } // and now remove excess parts if the iterable has shrunk. while (entries.length > i) { const entry = entries.pop() assert(entry) entry._part(null) } old_value = undefined return } else if (entries) { for (const entry of entries) entry._part(null) entries = undefined } if (is_html(value)) { const { _dynamics: dynamics, _statics: statics } = value const template = compile_template(statics) assert( template._parts.length === dynamics.length, 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?', ) if (old_template !== template) { if (template_parts !== undefined) { // scan through all the parts of the previous tree, and clear any renderables. for (const [_idx, part] of template_parts) part(null) template_parts = undefined } old_template = template const doc = old_template._content.cloneNode(true) as DocumentFragment const node_by_part: Array = [] for (const node of doc.querySelectorAll('[data-dynparts]')) { const parts = node.getAttribute('data-dynparts') assert(parts) node.removeAttribute('data-dynparts') for (const part of parts.split(' ')) node_by_part[+part] = node } for (const part of old_template._root_parts) node_by_part[part] = span // the fragment must be inserted before the parts are constructed, // because they need to know their final location. // this also ensures that custom elements are upgraded before we do things // to them, like setting properties or attributes. delete_contents(span) insert_node(span, doc) template_parts = template._parts.map(([dynamic_index, [type, data]], element_index): [number, Part] => { const node = node_by_part[element_index] switch (type) { case PART_CHILD: let child: ChildNode | null if (node instanceof Node) { child = node.childNodes[data] assert(child) } else { child = node._start.nextSibling assert(child) for (let i = 0; i < data; i++) { child = child.nextSibling assert(child !== null, 'expected more siblings') assert(child !== node._end, 'ran out of siblings before the end') } } assert(child.parentNode && child.previousSibling && child.nextSibling) return [ dynamic_index, create_child_part({ _start: child.previousSibling, _end: child.nextSibling, }), ] case PART_DIRECTIVE: assert(node instanceof Node) return [dynamic_index, create_directive_part(node)] case PART_ATTRIBUTE: assert(node instanceof Element) return [dynamic_index, create_attribute_part(node, data)] case PART_PROPERTY: assert(node instanceof Node) return [dynamic_index, create_property_part(node, data)] } }) } assert(template_parts) for (const [idx, part] of template_parts) part(dynamics[idx]) old_value = undefined return } if (!Object.is(old_value, value)) { // if we previously rendered a tree that might contain renderables, // and the template has changed (or we're not even rendering a template anymore), // we need to clear the old renderables. disconnect_root() if (old_value != null && value !== null && !(old_value instanceof Node) && !(value instanceof Node)) { // we previously rendered a string, and we're rendering a string again. assert(span._start.nextSibling?.nextSibling === span._end && span._start.nextSibling instanceof Text) span._start.nextSibling.data = '' + value } else { delete_contents(span) if (value !== null) insert_node(span, value instanceof Node ? value : new Text('' + value)) } old_value = value } } } export function create_property_part(node: Node, name: string): Part { return value => { // @ts-expect-error node[name] = value } } export function create_attribute_part(node: Element, name: string): Part { return value => set_attr(node, name, value) } export type Directive = (el: Element) => Cleanup export function create_directive_part(node: Node): Part { let cleanup: Cleanup let prev_fn: unknown return fn => { if (prev_fn === fn) return assert(typeof fn === 'function' || fn == null) cleanup?.() cleanup = fn?.(node) prev_fn = fn } } function set_attr(el: Element, name: string, value: unknown) { if (typeof value === 'boolean') el.toggleAttribute(name, value) else if (value == null) el.removeAttribute(name) // the cast is fine because setAttribute implicitly casts the value to a string else el.setAttribute(name, value as string) } export function attr_directive(name: string, value: string | boolean | null | undefined): Directive { return el => { set_attr(el, name, value) return () => set_attr(el, name, null) } } export function on_directive( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, ): Directive { return el => { el.addEventListener(type, listener, options) return () => el.removeEventListener(type, listener, options) } }