a post-component library for building user-interfaces on the web.
1import {
2 assert,
3 is_html,
4 is_iterable,
5 is_keyed,
6 is_renderable,
7 single_part_template,
8 type Displayable,
9 type Key,
10 type Renderable,
11} from '../shared.ts'
12import {
13 compile_template,
14 PART_ATTRIBUTE,
15 PART_CHILD,
16 PART_DIRECTIVE,
17 PART_PROPERTY,
18 type CompiledTemplate,
19} from './compiler.ts'
20import { controllers, get_controller } from './controller.ts'
21import { create_span_after, delete_contents, extract_contents, insert_node, type Span } from './span.ts'
22import type { Cleanup } from './util.ts'
23
24export type Part = (value: unknown) => void
25
26export function create_child_part(
27 span: Span,
28
29 // for when we're rendering a renderable:
30 needs_revalidate = true,
31 current_renderable?: Renderable,
32
33 // for when we're rendering a template:
34 old_template?: CompiledTemplate,
35 template_parts?: [number, Part][],
36
37 // for when we're rendering multiple values:
38 entries?: Array<{ _span: Span; _part: Part; _key: Key }>,
39
40 // for when we're rendering a string/single dom node:
41 // undefined means no previous value, because a user-specified undefined is remapped to null
42 old_value?: unknown,
43): Part {
44 function switch_renderable(next: Renderable | undefined) {
45 if (current_renderable && current_renderable !== next) {
46 const controller = controllers.get(current_renderable)
47 if (controller) {
48 controller._invalidate.delete(switch_renderable)
49
50 // If this was the last instance, call unmount callbacks
51 if (!controller._invalidate.size) {
52 controller._unmount_callbacks.forEach(callback => callback?.())
53 controller._unmount_callbacks.length = 0
54 }
55 }
56 }
57 current_renderable = next
58 }
59
60 function disconnect_root() {
61 if (template_parts !== undefined) {
62 for (const [, part] of template_parts) part(null)
63 old_template = undefined
64 template_parts = undefined
65 }
66 }
67
68 return function update(value) {
69 if (is_renderable(value)) {
70 if (!needs_revalidate && value === current_renderable) return
71 needs_revalidate = false
72
73 switch_renderable(value)
74
75 const renderable = value
76 const controller = get_controller(renderable)
77 // If this is the first mounted instance, call mount callbacks
78 if (!controller._invalidate.size) {
79 controller._unmount_callbacks = controller._mount_callbacks.map(callback => callback())
80 }
81 controller._invalidate.set(switch_renderable, () => {
82 assert(renderable === current_renderable)
83 needs_revalidate = true
84 update(renderable)
85 })
86
87 try {
88 value = renderable.render()
89 } catch (thrown) {
90 if (is_html(thrown)) {
91 value = thrown
92 } else {
93 throw thrown
94 }
95 }
96
97 // if render returned another renderable, we want to track/cache both renderables individually.
98 // wrap it in a nested ChildPart so that each can be tracked without ChildPart having to handle multiple renderables.
99 if (is_renderable(value)) value = single_part_template(value)
100 } else switch_renderable(undefined)
101
102 // if it's undefined, swap the value for null.
103 // this means if the initial value is undefined,
104 // it won't conflict with old_value's default of undefined,
105 // so it'll still render.
106 if (value === undefined) value = null
107
108 // NOTE: we're explicitly not caching/diffing the value when it's an iterable,
109 // given it can yield different values but have the same identity. (e.g. arrays)
110 if (is_iterable(value)) {
111 if (!entries) {
112 // we previously rendered a single value, so we need to clear it.
113 disconnect_root()
114 delete_contents(span)
115 entries = []
116 }
117
118 // create or update a root for every item.
119 let i = 0
120 let end = span._start
121 for (const item of value) {
122 const key = is_keyed(item) ? item._key : (item as Key)
123 if (entries.length <= i) {
124 const span = create_span_after(end)
125 entries[i] = { _span: span, _part: create_child_part(span), _key: key }
126 }
127
128 if (key !== undefined && entries[i]._key !== key) {
129 for (let j = i + 1; j < entries.length; j++) {
130 const entry1 = entries[i]
131 const entry2 = entries[j]
132
133 if (entry2._key === key) {
134 // swap the contents of the spans
135 const tmp_content = extract_contents(entry1._span)
136 insert_node(entry1._span, extract_contents(entry2._span))
137 insert_node(entry2._span, tmp_content)
138
139 // swap the spans back
140 const tmp_span = { ...entry1._span }
141 Object.assign(entry1._span, entry2._span)
142 Object.assign(entry2._span, tmp_span)
143
144 // swap the roots
145 entries[j] = entry1
146 entries[i] = entry2
147
148 break
149 }
150 }
151
152 entries[i]._key = key
153 }
154
155 entries[i]._part(item as Displayable)
156 end = entries[i]._span._end
157 i++
158 }
159
160 // and now remove excess parts if the iterable has shrunk.
161 while (entries.length > i) {
162 const entry = entries.pop()
163 assert(entry)
164 entry._part(null)
165 }
166
167 old_value = undefined
168 return
169 } else if (entries) {
170 for (const entry of entries) entry._part(null)
171 entries = undefined
172 }
173
174 if (is_html(value)) {
175 const { _dynamics: dynamics, _statics: statics } = value
176 const template = compile_template(statics)
177
178 assert(
179 template._parts.length === dynamics.length,
180 'expected the same number of dynamics as parts. do you have a ${...} in an unsupported place?',
181 )
182
183 if (old_template !== template) {
184 if (template_parts !== undefined) {
185 // scan through all the parts of the previous tree, and clear any renderables.
186 for (const [_idx, part] of template_parts) part(null)
187 template_parts = undefined
188 }
189
190 old_template = template
191
192 const doc = old_template._content.cloneNode(true) as DocumentFragment
193
194 const node_by_part: Array<Node | Span> = []
195
196 for (const node of doc.querySelectorAll('[data-dynparts]')) {
197 const parts = node.getAttribute('data-dynparts')
198 assert(parts)
199 node.removeAttribute('data-dynparts')
200 for (const part of parts.split(' ')) node_by_part[+part] = node
201 }
202
203 for (const part of old_template._root_parts) node_by_part[part] = span
204
205 // the fragment must be inserted before the parts are constructed,
206 // because they need to know their final location.
207 // this also ensures that custom elements are upgraded before we do things
208 // to them, like setting properties or attributes.
209 delete_contents(span)
210 insert_node(span, doc)
211
212 template_parts = template._parts.map(([dynamic_index, [type, data]], element_index): [number, Part] => {
213 const node = node_by_part[element_index]
214 switch (type) {
215 case PART_CHILD:
216 let child: ChildNode | null
217
218 if (node instanceof Node) {
219 child = node.childNodes[data]
220 assert(child)
221 } else {
222 child = node._start.nextSibling
223 assert(child)
224 for (let i = 0; i < data; i++) {
225 child = child.nextSibling
226 assert(child !== null, 'expected more siblings')
227 assert(child !== node._end, 'ran out of siblings before the end')
228 }
229 }
230
231 assert(child.parentNode && child.previousSibling && child.nextSibling)
232
233 return [
234 dynamic_index,
235 create_child_part({
236 _start: child.previousSibling,
237 _end: child.nextSibling,
238 }),
239 ]
240 case PART_DIRECTIVE:
241 assert(node instanceof Node)
242 return [dynamic_index, create_directive_part(node)]
243 case PART_ATTRIBUTE:
244 assert(node instanceof Element)
245 return [dynamic_index, create_attribute_part(node, data)]
246 case PART_PROPERTY:
247 assert(node instanceof Node)
248 return [dynamic_index, create_property_part(node, data)]
249 }
250 })
251 }
252
253 assert(template_parts)
254 for (const [idx, part] of template_parts) part(dynamics[idx])
255
256 old_value = undefined
257 return
258 }
259
260 if (!Object.is(old_value, value)) {
261 // if we previously rendered a tree that might contain renderables,
262 // and the template has changed (or we're not even rendering a template anymore),
263 // we need to clear the old renderables.
264 disconnect_root()
265
266 if (old_value != null && value !== null && !(old_value instanceof Node) && !(value instanceof Node)) {
267 // we previously rendered a string, and we're rendering a string again.
268 assert(span._start.nextSibling?.nextSibling === span._end && span._start.nextSibling instanceof Text)
269 span._start.nextSibling.data = '' + value
270 } else {
271 delete_contents(span)
272 if (value !== null) insert_node(span, value instanceof Node ? value : new Text('' + value))
273 }
274
275 old_value = value
276 }
277 }
278}
279
280export function create_property_part(node: Node, name: string): Part {
281 return value => {
282 // @ts-expect-error
283 node[name] = value
284 }
285}
286
287export function create_attribute_part(node: Element, name: string): Part {
288 return value => set_attr(node, name, value)
289}
290
291export type Directive = (el: Element) => Cleanup
292
293export function create_directive_part(node: Node): Part {
294 let cleanup: Cleanup
295 let prev_fn: unknown
296 return fn => {
297 if (prev_fn === fn) return
298 assert(typeof fn === 'function' || fn == null)
299 cleanup?.()
300 cleanup = fn?.(node)
301 prev_fn = fn
302 }
303}
304
305function set_attr(el: Element, name: string, value: unknown) {
306 if (typeof value === 'boolean') el.toggleAttribute(name, value)
307 else if (value == null) el.removeAttribute(name)
308 // the cast is fine because setAttribute implicitly casts the value to a string
309 else el.setAttribute(name, value as string)
310}
311
312export function attr_directive(name: string, value: string | boolean | null | undefined): Directive {
313 return el => {
314 set_attr(el, name, value)
315 return () => set_attr(el, name, null)
316 }
317}
318
319export function on_directive(
320 type: string,
321 listener: EventListenerOrEventListenerObject,
322 options?: boolean | AddEventListenerOptions,
323): Directive {
324 return el => {
325 el.addEventListener(type, listener, options)
326 return () => el.removeEventListener(type, listener, options)
327 }
328}