Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

example of a progressive, self hydrating, custom element #2

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/runtime.js
Original file line number Diff line number Diff line change
@@ -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);
};
34 changes: 33 additions & 1 deletion src/wcc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand All @@ -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];
Expand All @@ -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);
}
Expand All @@ -49,6 +52,19 @@ app.get('/*', async (request, reply) => {
<html>
<head>
<title>WCC - SSR</title>

<!-- <script src="./src/runtime.js"></script> -->

${
hydrateJs.map(f => {
return `
<script type="module">
${f}
</script>
`
})
}

${
eagerJs.map(script => {
return `<script type="module" src="${script.moduleURL.pathname.replace(process.cwd(), '')}"></script>`;
Expand Down
77 changes: 77 additions & 0 deletions www/components/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const template = document.createElement('template');

template.innerHTML = `
<style>
h6 {
color: red;
font-size: 25px;
}

h6.hydrated {
animation-duration: 3s;
animation-name: slidein;
}

@keyframes slidein {
from {
margin-left: 100%;
width: 300%;
}

to {
font-size: 25px;
}
}
</style>

<h6>This is a test</h6>
`;

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)
3 changes: 3 additions & 0 deletions www/pages/index.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -35,6 +36,8 @@ export default class HomePage extends HTMLElement {

<p id="spacer"></p>

<wcc-test></wcc-test>

<wcc-footer hydrate="lazy"></wcc-footer>
`;
}
Expand Down