diff --git a/src/runtime.js b/src/runtime.js new file mode 100644 index 0000000..ab84cc8 --- /dev/null +++ b/src/runtime.js @@ -0,0 +1,27 @@ +// Proxy ? +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy + +// A +// function customDefine(tagName, BaseClass) { +// console.debug('intercepted customElement.define', { tagName, BaseClass }); +// } + +// new Proxy(customElements.__proto__.define, customDefine) + +// new Proxy(customElements.__proto__, { +// define: customDefine +// }); + +/* eslint-disable no-underscore-dangle */ +const backupDefine = customElements.define.bind(window.customElements); + +window.customElements.define = (tagName, BaseClass) => { + console.debug('intercepted customElement.define', { tagName, BaseClass }); + + if (BaseClass.__secret) { + console.debug('hmmmm... wonder what could we do here????'); + BaseClass.__secret(); + } + + backupDefine(tagName, BaseClass); +}; \ No newline at end of file diff --git a/src/wcc.js b/src/wcc.js index 53c7804..a1c772f 100644 --- a/src/wcc.js +++ b/src/wcc.js @@ -7,7 +7,9 @@ import { parseFragment, serialize } from 'parse5'; import fs from 'node:fs/promises'; +// TODO better data structure for deps and hydrate function? const deps = []; +const hydrateFunctions = new Map(); async function renderComponentRoots(tree) { for (const node of tree.childNodes) { @@ -52,17 +54,38 @@ async function registerDependencies(moduleURL) { ecmaVersion: 'latest', sourceType: 'module' }), { + + // walk custom element class for internal methods to expose at runtime + // for supporting hydration and lazy loading strategies + async ClassDeclaration(node) { + if (node.superClass.name === 'HTMLElement') { + const name = node.id.name; + + // find __hydrate__ method + node.body.body.forEach((n) => { + if(n.type === 'MethodDefinition' && n.static && n.key.name === '__hydrate__') { + const innerFunction = moduleContents.slice(n.start, n.end); + + hydrateFunctions[name] = `(${innerFunction.replace('static __hydrate__()', '() => ')})()`; + } + }) + } + }, + + // walk import statements to find other custom element definitions async ImportDeclaration(node) { const dependencyModuleURL = new URL(node.source.value, moduleURL); await registerDependencies(dependencyModuleURL); }, + + // find customElement.define calls and track relevant metadata async ExpressionStatement(node) { const { expression } = node; // TODO don't need to update if it already exists if (expression.type === 'CallExpression' && expression.callee && expression.callee.object - && expression.callee.property && expression.callee.object.name === 'customElements' + && expression.callee.property && expression.callee.object.name === 'customElements' && expression.callee.property.name === 'define') { const tagName = node.expression.arguments[0].value; @@ -108,6 +131,15 @@ async function renderToString(elementURL, fragment = true) { elementInstance.shadowRoot.innerHTML = serialize(finalTree); + // link custom element definitions with their __hydrate__ function + for(const f in hydrateFunctions) { + for(const d in deps) { + if(f === deps[d].instanceName) { + deps[d].__hydrate__ = hydrateFunctions[f]; + } + }; + } + return { html: elementInstance.getInnerHTML({ includeShadowRoots: fragment }), assets: deps diff --git a/ssr.js b/ssr.js index d5694e8..6d3e4e0 100644 --- a/ssr.js +++ b/ssr.js @@ -10,8 +10,8 @@ app.register(fastifyStatic, { prefix: '/www' }); app.register(fastifyStatic, { - root: new URL('./lib', import.meta.url).pathname, - prefix: '/lib', + root: new URL('./src', import.meta.url).pathname, + prefix: '/src', decorateReply: false }); @@ -27,6 +27,7 @@ app.get('/*', async (request, reply) => { const { html, assets } = await renderToString(new URL(entryPoint, import.meta.url), false); const lazyJs = []; const eagerJs = []; + const hydrateJs = []; for (const asset in assets) { const a = assets[asset]; @@ -36,6 +37,8 @@ app.get('/*', async (request, reply) => { if (a.moduleURL.href.endsWith('.js')) { if (a.hydrate === 'lazy') { lazyJs.push(a); + } else if(a.__hydrate__) { + hydrateJs.push(a.__hydrate__); } else { eagerJs.push(a); } @@ -49,6 +52,19 @@ app.get('/*', async (request, reply) => { WCC - SSR + + + + ${ + hydrateJs.map(f => { + return ` + + ` + }) + } + ${ eagerJs.map(script => { return ``; diff --git a/www/components/test.js b/www/components/test.js new file mode 100644 index 0000000..acf1391 --- /dev/null +++ b/www/components/test.js @@ -0,0 +1,77 @@ +const template = document.createElement('template'); + +template.innerHTML = ` + + +
This is a test
+`; + +class TestComponent extends HTMLElement { + connectedCallback() { + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + this.shadowRoot.appendChild(template.content.cloneNode(true)); + } else { + const header = this.shadowRoot.querySelector('h6'); + + header.style.color = this.getAttribute('color'); + header.classList.add('hydrated'); + } + } + + static __hydrate__() { + console.debug('special __hydrate__ function from TestComponent :)'); + alert('special __hydrate__ function from TestComponent :)'); + let initialized = false; + + window.addEventListener('load', () => { + let options = { + root: null, + rootMargin: '20px', + threshold: 1.0 + }; + + let callback = (entries, observer) => { + entries.forEach(entry => { + console.debug({ entry }) + if(!initialized && entry.isIntersecting) { + alert('Intersected wcc-test, time to hydrate!!!'); + initialized = true; + document.querySelector('wcc-test').setAttribute('color', 'green'); + import(new URL('./www/components/test.js', import.meta.url)); + } + }); + }; + + let observer = new IntersectionObserver(callback, options); + let target = document.querySelector('wcc-test'); + + observer.observe(target); + }) + } +} + +export { TestComponent } + +customElements.define('wcc-test', TestComponent) \ No newline at end of file diff --git a/www/pages/index.js b/www/pages/index.js index 8de843d..608ca43 100644 --- a/www/pages/index.js +++ b/www/pages/index.js @@ -1,6 +1,7 @@ import '../components/counter.js'; import '../components/footer.js'; import '../components/header.js'; +import '../components/test.js'; export default class HomePage extends HTMLElement { constructor() { @@ -35,6 +36,8 @@ export default class HomePage extends HTMLElement {

+ + `; }