-
-
Notifications
You must be signed in to change notification settings - Fork 3
/
define-component.ts
385 lines (321 loc) · 14.3 KB
/
define-component.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
import { toKebabCase } from 'js-convert-case';
import { compose, required, matchesPattern, shouldThrow, withMessage } from '@tybalt/validator';
import { standard } from '@tybalt/parser';
import { derive, reactive } from '@tybalt/reactive';
import { Context, ContextEvent } from '@tybalt/context';
import type { Reactive } from '@tybalt/reactive';
import render from './render';
import type { DefineComponentsOptions, SetupContext } from '../types';
const nameValidator = shouldThrow(
withMessage(
compose(required(), matchesPattern(/.*-.*/)),
`web component names are required and must contain a hyphen`,
),
);
export default ({
name,
emits,
props = {},
setup,
connectedCallback,
disconnectedCallback,
adoptedCallback,
render: passedRender,
shadowMode = 'open',
css,
template,
contexts = [],
}: DefineComponentsOptions) => {
nameValidator.validate(name);
const clazz = class extends HTMLElement {
// Closed shadow roots aren't attached to the class instance by default, so we
// grab a reference to it ourselves for later use.
#shadowRoot: ShadowRoot;
// The context object passed to the component definition's setup method
#setupContext: SetupContext;
// A hash from the attribute name to its corresponding reactive and parser
#props: {
[Property: string]: {
reactive: Reactive<any>;
parser: { parse(str: string | null): any };
value: any;
};
} = {};
// A hash from the render state key to its corresponding reactive (returned from the setup method)
#renderState: Map<string, Reactive<any>> = new Map();
// The render method from the component definition
#render = passedRender;
// The css string or function from the component definition
#css = css;
// The template string from the component definition
#template = template;
// Whether or not the component is currently connected to the dom
#isConnected = false;
// All of the contexts to connect to
// https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
#contexts = new Map<Context<unknown>, { reactive: Reactive<any>; unsubscribe?: () => void }>();
contextState: any = undefined;
constructor() {
super();
this.#props = Object.entries(props).reduce(
(accumulator, [key, value]) => {
const parser = value.parser || standard;
let initialValue: any = null;
try {
initialValue = parser.parse(value.default);
} catch (e) {
initialValue = e;
}
const entry = {
reactive: reactive(initialValue),
parser: value.parser || standard,
value: initialValue,
};
entry.reactive.addListener((value: any) => (entry.value = value));
accumulator[key] = entry;
return accumulator;
},
{} as {
[Property: string]: {
reactive: Reactive<any>;
parser: { parse(str: string | null): any };
value: any;
};
},
);
for (const [contextName, context] of Object.entries(contexts)) {
const contextReactive = reactive(context);
this.dispatchEvent(
new ContextEvent(
context,
(value, unsubscribe) => {
const contextState = this.#contexts.get(context) || {
value: undefined,
unsubscribe: undefined,
};
// Call the old unsubscribe callback if the unsubscribe call has
// changed. This probably means we have a new provider.
if (unsubscribe !== contextState.unsubscribe) {
contextState.unsubscribe?.();
contextState.unsubscribe = unsubscribe;
}
contextReactive.value = value;
this.#contexts.set(context, { unsubscribe, reactive: contextReactive });
},
true,
),
);
this.#contexts.set(context, { reactive: contextReactive });
/**
* We want to make prop values and contexts available in the render function without needing to
* pass them in the setup function. We don't want to shadow them if the dev wants to
* reuse derived state with the same name in their render function or template.
*/
if (!this.#props[contextName]) {
this.#renderState.set(contextName, contextReactive);
} else {
/**
* DBW 12/6/23: I would prefer to throw here, but I can't catch the error and I can catch the log line
* in the unit tests, so we log because its what we can test 🤣.
*/
console.warn(`Collision detected between context and prop: ${contextName}`);
}
}
// This is the method for clients to use to emit events
const emit = (type: string, detail: any) => {
if (emits && !emits?.includes(type)) {
console.warn(`unexpected event emitted with type ${type} and detail ${detail}`);
}
this.dispatchEvent(new CustomEvent(type, { detail }));
};
this.#setupContext = {
emit,
};
const propsForSetup: { [key: string]: { subscribe: () => void; reactive: Reactive<any> } } =
Object.fromEntries([
...Object.entries(this.#props).map(([key, value]) => [key, value.reactive]),
...Array.from(this.#contexts.entries()).map(([key, value]) => [key, value.reactive]),
]);
const setupResults = setup?.call(this, propsForSetup, this.#setupContext) || {};
for (const [key, value] of Object.entries({ ...propsForSetup, ...setupResults })) {
if (value.addListener) {
this.#renderState.set(key, value);
} else if (typeof value === 'function') {
this.#renderState.set(key, value);
} else {
this.#renderState.set(key, reactive(value));
}
}
for (const [attributeName, propValue] of Object.entries(this.#props)) {
if (!this.#renderState.get(attributeName)) {
const parsedReactive = derive(propValue.reactive, ([newValue]: Reactive<string>[]) => {
return propValue.parser.parse(newValue.value);
});
this.#renderState.set(attributeName, parsedReactive);
}
}
/**
* We don't support web components that don't use the shadow dom, and I don't think we ever
* should. We would have to get away from the template tag method of rendering, and that is
* contrary to the "use the platform" ethos we're building on.
*/
this.#shadowRoot = this.attachShadow({ mode: shadowMode });
this.#doRender();
}
connectedCallback() {
this.#isConnected = true;
connectedCallback?.apply(this);
this.#updateContexts();
this.#updateProps();
this.#doRender();
}
disconnectedCallback() {
this.#isConnected = false;
disconnectedCallback?.apply(this);
for (const context of this.#contexts.values()) {
context.unsubscribe?.();
}
}
adoptedCallback() {
adoptedCallback?.apply(this);
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
const entry = this.#props[name];
const parsed = entry.parser.parse(newValue);
entry.reactive.value = parsed;
this.#doRender();
}
#doRender() {
if (!this.#isConnected || !this.#shadowRoot) {
return;
}
this.#shadowRoot.innerHTML = '';
/**
* Note that the order of the rendered template is always
*
* <shadow-root>
* <!-- Our generated styles-->
* <style></style>
*
* <!-- The user's styles -->
* <style></style>
*
* <!-- The user's render results -->
* <render></render>
*
* <!-- The user's template -->
* <template></template>
* </shadow-root>
*
* I'm not particularly certain what the consequences of that ordering is and
* whether we need to make the order configurable.
*/
if (this.#css) {
const styleElement = document.createElement('style');
/**
* dbw 1/4/23: There's a tradeoff between thrash from recalculating styles on every render
* because you re-render all the script tags, and the convenience of computed styles in
* components. I've opted to allow developers to thrash styles as hard as they want, in case
* that turns out to be a good idea (it seems conceivable, for instance, that your leaf
* components can change computed styles on every render inexpensively because they're in
* the shadow dom), but to use documentation to lean towards a "css in a separate file"
* approach that discourages its use.
*/
const calculatedCss = typeof css === 'function' ? css(this.#renderState) || '' : css;
styleElement.innerHTML = calculatedCss || '';
this.#shadowRoot?.appendChild(styleElement);
}
if (this.#render) {
const renderResults = this.#render(Object.fromEntries(this.#renderState));
let renderedNodes;
if (renderResults) {
renderedNodes = render(renderResults);
} else {
renderedNodes = [];
}
for (let i = 0; i < renderedNodes.length; i++) {
try {
this.#shadowRoot?.appendChild(renderedNodes[i]);
} catch (e) {
console.error(e);
}
}
for (let i = 0; i < renderedNodes.length; i++) {
try {
const childNode = renderedNodes[i];
this.#shadowRoot?.appendChild(childNode);
} catch (e) {
console.error(e);
}
}
}
if (this.#template) {
const templateElement = document.createElement('template');
templateElement.innerHTML = this.#template;
const templateContent = templateElement.content;
this.#shadowRoot?.appendChild(templateContent.cloneNode(true));
}
const renderReactiveListener = () => {
this.#doRender();
};
for (const renderReactive of this.#renderState.values()) {
if (!renderReactive?.addListener) {
continue;
}
if (renderReactive.isForcingRerenderOnUpdate) {
renderReactive.addListener(renderReactiveListener);
}
}
}
/**
* Pushes the current value of all props into their corresponding reactives. Called
* on connectedCallback.
*/
#updateProps() {
for (const [key, value] of Object.entries(this.#props)) {
const attributeValue = this.getAttribute(toKebabCase(key));
const usingDefault = attributeValue === null && value.value;
const areDifferent = attributeValue !== value.value;
if (!usingDefault && areDifferent) {
const nextValue = value.parser.parse(attributeValue);
value.reactive.value = nextValue;
}
}
}
/**
* Once we connect to the dom, we need to update the context reactives with the
* current value of the context. This is called on connectedCallback.
*
* Similar to #updateProps, but for contexts.
*/
#updateContexts() {
for (const [context, { reactive }] of this.#contexts.entries()) {
this.dispatchEvent(
new ContextEvent(
context,
(value, unsubscribe) => {
const contextState = this.#contexts.get(context) || {
value: undefined,
unsubscribe: undefined,
};
// Call the old unsubscribe callback if the unsubscribe call has
// changed. This probably means we have a new provider.
if (unsubscribe !== contextState.unsubscribe) {
contextState.unsubscribe?.();
contextState.unsubscribe = unsubscribe;
}
reactive.value = value;
},
true,
),
);
}
}
};
try {
customElements.define(name, clazz);
} catch (e) {
console.warn(`failed to define component ${name}`, e);
}
return clazz;
};