Coder Social home page Coder Social logo

danieldietrich / candid Goto Github PK

View Code? Open in Web Editor NEW
6.0 6.0 0.0 297 KB

Candid is a surprisingly fresh and frameworkless JavaScript library for building web applications.

License: MIT License

HTML 25.15% JavaScript 4.59% CSS 0.16% TypeScript 70.11%
customelement declarative frameworkless frontend javascript library template typescript ui web webcomponent

candid's People

Contributors

danieldietrich avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

candid's Issues

Proper dynamic class definition using the prototype chain

The current custom class definition works find (at least in Chrome and Safari). Do we additionally need to define the constructor explicitly?

CustomElement.prototype.constructor = CustomElement.prototype.constructor`

We should test all scenarios, including new(), document.createElement('my-element') and maybe even Reflect.construct().

Fuse this, this.root and this.element

Motivation: the user must not think about

  • where to store information
  • where to query information

Status quo:

  • If no shadow root exists this.root = this.element + this[__ctx]
  • I a shadow root exists this.root = this.element.shadowRoot + this[__ctx]

Goal:

  • replace this[__ctx] with this => the previous this.element is now this
  • remove this.root. if the user uses mode='open' or mode='closed', then this.shadowRoot is the root, otherwise this (the user needs to take care of it, the default is no shadow root, so it should be transparent)

Web component descriptor

I don't think we need to expose reflective information about the web component in the context object, because most information is already available and the use cases are rare:

  • name: this.tagName.toLowerCase() (custom element) or this.getAttribute('is') (customized built-in element)
  • extends: this.getAttribute('is') ? this.getAttribute('is') : undefined
  • type: this instanceof ... and customElements.get(name) (constructor)
  • mode: mode = this.shadowRoot?.mode

Error extending native elements in Safari

Core issue: create a custom element which is a subclass of a native DOM element using the ES5 prototype chain. Current problem is the instantiation using new().

Looks like lit.dev does not support it. One more reason for us to do it!


This thread

https://dev.to/lkraav/comment/ad06

Browser support

See caniuse.com.

As of 2022-02-02

can-i-use

Solution / workaround

<!-- this should be on top of your HTML <head> scripts -->
<!-- it fixes customized built-in element in Safari and older browsers -->
<script src="//cdn.jsdelivr.net/npm/@ungap/custom-elements"></script>

Add JSX support and a vanilla JS API

Update: we need to distinguish this issue into two tasks:

  • [๐Ÿ™…โ€โ™€๏ธ wontfix] there exist JSX DOM factories on npmjs.com, we do not ship them as part of candid. The JSX binding is part of the runtime env, not of the Candid lib.
  • provide a function to programmatically define web-components
type Options = {
  mode?: 'open' | 'closed' | null // shadow root mode (default: null)
  extends?: string | null // super tag name (default: null)
  props?: object | null // observed properties and default values
  template?: HTMLTemplateElement | null //  the html template
}

// Candid.createWebComponent()
function createWebComponent(name: string, options?: Options)

Question: Should createWebComponent() call customElements.define()?


Currently we use a declarative approach only and only have one public API candid(options) to initialize Candid.

We should think about exposing the JS API in order to allow to create web-components dynamically using JS. Maybe it also makes sense to expose the web-import API.

It is not sufficient to just export existing functions. Most probably we might need to re-design the internal API.

See also Write your own DOM element factory for TypeScript.

Add unit tests

There are already many edge cases (and their combinations) to be tested

  • eager and lazy web-imports
  • cascaded web-components and web-imports
  • cyclic components and imports
  • absolute and relative imports (also bouncing between different servers)
  • Content-Security-Policy (CSP) regarding eval
  • creating user defined elements (without extending tags)
  • extending native elements
  • order of lifecycle method calls, especially on async initialization and when manually creating an element using document.createElement('my-elem') + adding attributes and properties before connecting the element to the live DOM
  • use no mode, mode open and mode closed
  • etc.

Re-add validation

We once had element validation and removed it. I think it is a good idea to re-add it, even if the messages/strings blow up the library.

Move the props declaration to the script

Currently we define default values. It would be better to define types.

props: {
  selected: Boolean,
  index: Number
}

One challenge is to evaluate the script before defining the custom class because the prop names are needed for these static observedAttributes method.

Root

If the web-component has mode="open" or mode="closed" then the root is of type ShadowRoot.

const { root } = this.ctx;

// = true
root instanceof ShadowRoot

// = false
root === this

If the web-component has no mode set, then the root is the element.

const { root } = this.ctx;

// = true
root instanceof HTMLElement

// = true
root === this

Create a command line tool

Names:

  • package name candid-cli
  • command candid

Commands:

  • init - initialize a full fledged web project
  • bundle - bundle web imports in order to reduce number of fetches

Create example use cases

  • infinite-scroller that lazily displays images
  • a (mobile friendly) keyboard that exposes key events from the Shadow DOM
  • a copy-to-clipboard tool
  • ...

Allow to load arbitrary HTML code downstream

This feature is triggered by Candid's <web-component> tag. Currently we have to define the whole code of each component within one big index.html file. Without doubt, that was the goal of this library, having just HTML instead of build tools. However, it would be nice to be able to organize the markup (HTML tags, CSS <style>, JS <script> etc.) into smaller portions that are either loaded eagerly on page load or lazily on web component instantiation.

We will introduce a tag <web-import> which works like this:

  • the href attribute points to a relative or an absolute URL containing arbitrary content
  • When Candid processes the <web-import>, it will additionally set the status attribute. The initial status is loading. Eventually it will be either ok or error. If the state is ok, the web component's innerHTML will be updated with the fetched result. If the state is error, the innerText will be updated with the error message.
  • Initially, the document's <web-import> elements are processed. Those within <template> tags are skipped.
  • When a web-component is instantiated (e.g. by upgrading a tag), all <web-import> tags of the <template>'s content are processed if their state isn't set, yet.
  • Because a web-component's template is cloned when the web-component is instantiated, we need do defer the clone operation util the <web-import> result has been loaded
  • If one or more lazily loaded <web-imports>'s of a web-component's template have failed to load or errored, the web-component should not be upgraded.

Decide on the HTML API of web-component

Customized built-in element

<template is="web-component">
  <!-- definition -->
</template>

Pros

  • the native <template> is not rendered
  • concise (only one tag)

Cons

  • not supported by Safari
  • unfamiliar "is" syntax

Workarounds

  • include polyfill for Safari (makes page slower)

Autonomous custom element

<web-component>
  <template>
    <!-- definition -->
  </template>
</web-component>

Pros

  • supported by all major browsers, including Safari
  • familiar syntax

Cons

  • more clutter to write
  • web-component is rendered by the browser

Workarounds

  • we could remove all web-components from the DOM after custom element definition but it would trigger page renders
  • we could add <style>web-component { display: none !important; }</style>

My feeling is that we should go back to <web-component> because it is supported by all browsers.

Candid is unopinionated. We do not include polyfills or internally add additional styles to hide elements. It is the decision of the user to do so.

Context

All scripts run once on initialization. During that time, this is writable.

After initialization, this is sealed (from the viewpoint of a web-component's <script>).

Collect subsequent attribute/property changes before rendering the changes

I like the declarative rendering in React. It is based on virtual DOM diff. Web components have the attributeChangedCallback: each attribute change will lead to a call. It leads to code that looks like a big switch statement, each case directly mutates the DOM.

I want to think in directions, which lead more in direction of what React is doing, without being too rigid about how devs use the web APIs.

JS is single threaded. That means, Candid could 'record' one changes, by enqueueing an onUpdate call, while writing subsequent attribute changes (which are performed by the current "thread") to one object. That way, the onUpdate function of the user-space would be called only once and receive a complete object of changed properties instead of being called multiple times, receiving oldValue, newValue tuples.

The main benefit I see is that the component logic could be more complex. We would still need to modify the DOM directly (in contrast to React, which diffs virtual DOMs), however, we could take the whole property object into account when rendering changes. That would be more efficient than processing subsequent property-changes.

Support <script src="...."> in web component templates?

Currently, the component processor only reads <script> contents. It would be nice to by able to load scripts dynamically. Maybe we can just leave the <script> tags in the DOM (resp. as part of the template) and the browser does the job when cloning the template to the custom element root?

I see the following problems with that approach:

  • a <script src="..."> cannot bind this. we currently rely on that because a script sees the component's __ctx object as this. I don't see how this should work...
  • when a component is loaded from a different location, a relative url in src does not point to the right resource anymore. we could fix that by rewriting relative urls with the right base-url

See also #20

Remove web component definitions from DOM after defining them?

It does not make sense to keep child web components (that are children of a web component's template) because the row content is copied to the DOM, including the template.

Screen Shot 2022-02-03 at 01 28 18

Decision

  • we should remove child web components when processing a template

Implications

  • we remove all web components from the html page
  • we need to process web imports first, currently we need to process them last because otherwise we seem to define custom elements multiple times then, which leads to errors
  • we don't need to set the status anymore on web import tags, which simplifies the code, we just log error on the console

Related

We should hide tags. This can be either done with CSS:

web-component, web-import {
  display: none !important;
}

Alternatively this can be done declaratively:

<web-component name="..." hide></web-component>

<web-import src="..." hide></web-component>

Events

Motivation: [Browser events] bubble up the DOM tree. These kind of eventing can be used to send synthetic events. However, I see the need for eventing between distinct branches of the DOM tree.

Example: communication between <head> and <body>

I know that it is easy to set the title of an HTML page directly. This is just an example of eventing between two unrelated elements.

<head>
  <!-- listenes on the message bus for title changes -->
  <title is"my-title">Waiting for a nav link click...</title>
</head>
<body>
  <nav>
    <ol>
        <!-- a click sends an event over a message bus with a title -->
        <li><a is="my-a" href="#">Flip</a></li>
        <li><a is="my-a" href="#">Flap</a></li>
    </ol>
  </nav>
  <web-import src="./my-components.html"></web-import>
  <script type="module" src="//esm.run/candid"></script>
</body>

Thoughts

The eventing I described above follows the publisher subscriber (pub-sub) pattern. It is the simples form for general purpose eventing I can imagine.

Here is an example where it does not make sense so much to use the pub-sub pattern for communication. In particular, we have a my-form type aggregating my-input elements. If we have multiple instances of the form at the same time, keydown events of one input are received by every form that is currently mounted to the DOM. This is a good example, where the native event bubbling would make more sense. Each form wants to receive the (synthetic) events of its children.

<body>
  <form is="my-form">
    <input is="my-input"></input>
  </form>
  <form is="my-form">
    <input is="my-input"></input>
  </form>
</body>
<web-component name="my-form" extends="form">
  <template>
    <script>
      const onKeyDown = (e) => { console.log('key down', e) };
      this.onMount = () => { this.subscribe('my-keydown', onKeyDown) }
      this.onUnmount = () => { this.unsubscribe('my-keydown', onKeyDown) }
    </script>
  </template>
</web-component>
<web-component name="my-input" extends="input">
  <template>
    <script>
      this.element.onKeyDown(e => this.send('my-keydown', e));
    </script>
  </template>
</web-component>

Of course there are multiple solutions for this problem of receiving too many messages. One would be to add the form id or name to the message object and filter the messages by the form id. For now I would not provide a special filter API. All the (business) logic should be located in the <script> tags of the web-component.

Implementation

Candid would maintain three internal functions (modulo naming):

  • subscribe: <T>(topic: string, subscriber: (e: T) => void) => (e: T) => void
  • unsubscribe: <T>(topic: string, subscriber: (e: T) => void) => boolean
  • send: (topic: string, message: any) => void

that are exposed in each context object of a web component:

<web-component name="foo-bar">
  <template>
    <script>
      // `this` points to the context object of this web component
      this.subscribe('user-defined-event', (e) => {});
      this.unsubscribe('user-defined-event', (e) => {});
      this.send('user-defined-event', arbitraryObjectOrEvent);
    </script>
  </template>
</web-component>

Web Component and web import behavior

  • web-component element sets style { display: none } on construction
  • web-import element sets style { display: none } on construction
  • only the first template of web-component is considered (if exists)

Provide mangled / minified output to further optimize the size.

Let's target less than 1.5 KB brotlied. Some internal identifiers are still clear-text for example, that can be optimized by maintaining a whitelist of identifiers that shall not be minified. Preact (Microbundle) is a example for doing so. However, we should stick to the default build tools offered by Vite.

Define error boundaries for async code

We use promises in order to process lazily loaded resources. I suggest to catch errors on the outer-most layer and report them using

console.error('[candid] An error occurred.', error)

Performance best bractices

Preload web resources for initial page load.

<head>
  <link rel="preload" href="./my-components.html" as="fetch">
</head>
<body>
  <web-import src="./my-components.html"></web-import>
</body>

Allow to chain libraries by applying side effects to certain nodes of interest

If a web component is loaded lazily using <web-import> for example, then there might be a style post-processor needed like TailwindCSS.

This can be achieved by passing a processor aka effect during initialization to Candid. When the DOM changes (e.g. because new web components are imported) then the effect is applied to the new nodes.

Candid itself also returns an effect that can be applied by other libraries when DOM nodes change (are added).

Chaining effecting HTML libraries

How to stop FOUC from happening with native Web Components

Using an app-content wrapper

<body>
  <!-- best practice: use a web component as root for the content in order to reduce FOUC -->
  <!-- also: don't use slots to prevent FOUC -->
  <app-content></app-content>
  <!-- the template content is processed before connecting it to the live DOM -->
  <script type="module">
    import candid from "./src/index.js";
    window.addEventListener('load', () => candid());
  </script>
</body>

Pre-styling unregistered elements

Before an element is upgraded you can target it in CSS using the :defined pseudo-class. This is useful for pre-styling a component. For example, you may wish to prevent layout or other visual FOUC by hiding undefined components and fading them in when they become defined.

Note: this does not help for customized built-in elements <div is="ext-div">Loading...</div>

Example: hide <app-drawer> before it's defined:

app-drawer:not(:defined) {
  /* Pre-style, give layout, replicate app-drawer's eventual styles, etc. */
  display: inline-block;
  height: 100vh;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

After <app-drawer> becomes defined, the selector (app-drawer:not(:defined)) no longer matches.

Source: google web fundamentals

See also https://stackoverflow.com/questions/62683430/how-to-stop-fouc-from-happening-with-native-web-components

State

Update:

bind script evaluation to this instead of this.ctx. also: call all lifecycle methods with this instead of this.ctx. can we just call onMount() instead of onMount.call(this) to simplify the code?

I am not sure about this. The more I think about it, this.ctx should be the this of all web-component scripts because I don't want to risk to collide with the HTMLElement API. We should don't touch it.

freeze this.ctx after creation

I think it is a good idea to freeze the ctx object after the script ran. This is what is known as open-closed priciple in oop: should be open for extension, but closed for modification

idea: maybe we can 'hide' the ready state in const ready = Object.isFrozen(this.ctx) and completely remove the ready attribute

yes by all means


Currently each web-component instance binds this.ctx to the scripts:

<web-component name="foo-bar">
  <template>
    <script>
      this.element; // = the HTML element of this web-component
      const { root, subscribe, unsubscribe, send } = this; // = the context object
    </script>
  </template>
</web-component>

I think it would be more natural to bind this to the HTML element and have a separate context property ctx:

<web-component name="foo-bar">
  <template>
    <script>
      this; // = the HTML element of this web-component
      const { root, subscribe, unsubscribe, send } = this.ctx; // = the context object
      // additionally, this.ctx has writable onMount, onUnmount, onUpdate, onAdopt
    </script>
  </template>
</web-component>

TODO

  • bind script evaluation to this instead of this.ctx. also: call all lifecycle methods with this instead of this.ctx. can we just call onMount() instead of onMount.call(this) to simplify the code?
  • freeze this.ctx after creation
  • idea: maybe we can 'hide' the ready state in const ready = Object.isFrozen(this.ctx) and completely remove the ready attribute

Programmatic binding vs reactive binding

Candid is 100% pure web. It does not ship with an HTML Template engine or with compiler-supported variable bindings.

โœ… Candid

Such reactivity would look like this but it would be a first step to a framework that re-invents the wheel (known from React, Vue, Svelte and also not-so-well-known like Aurelia).

โŒ Not Candid

However, it would be good to have 3rd party template engines in mind, like Lit or even JSX.

JS API

But these may change over time. Relying only on vanilla HTML/JS is the most flexible and unopinionated approach. It is most important to get the API right (see #55):

  • how do we use this (in a script)`
  • would it make sense if a component would expose the (internal) state? security aspects?
  • we need to get web API vs JS API right

Events (revised)

Candid claims to be unopinionated but ships with its own messaging solution (pub-sub). It is better to move pub-sub to its own library.

export type Topic = string;
export type Subscriptions = Set<Subscriber<any>>;
export type Subscriber<T> = (e: T) => void;
export type Unsubscribe = () => void;

export default {
    publish,
    subscribe
};

const topics: Map<Topic, Subscriptions> = new Map();

/**
 * Subscribes a subscriber to a certain topic.
 * Returns an unsubscribe function.
 */
function subscribe<T>(topic: Topic, subscriber: Subscriber<T>): Unsubscribe {
    let subscriptions = topics.get(topic);
    if (!subscriptions) {
        topics.set(topic, subscriptions = new Set());
    }
    subscriptions.add(subscriber);
    return () => { subscriptions!.delete(subscriber) };
}

/**
 * Publishes a message to all subscribers of a given topic.
 * The subscribers are informed in no particular order.
 */
function publish(topic: Topic, message: any): void {
    topics.get(topic)?.forEach(subscriber => {
        try {
            subscriber(message);
        } catch (err) {
            console.error("[pubsub] error publishing message:", err, "\n", { topic, message });
        }
    });
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.