UNPKG

@symbiotejs/symbiote

Version:

Symbiote.js - zero-dependency close-to-platform frontend library to build super-powered web components

367 lines (266 loc) 13.9 kB
[![Tests](https://github.com/symbiotejs/symbiote.js/actions/workflows/tests.yml/badge.svg)](https://github.com/symbiotejs/symbiote.js/actions/workflows/tests.yml) [![npm version](https://img.shields.io/npm/v/@symbiotejs/symbiote)](https://www.npmjs.com/package/@symbiotejs/symbiote) [![npm downloads](https://img.shields.io/npm/dm/@symbiotejs/symbiote)](https://www.npmjs.com/package/@symbiotejs/symbiote) ![bundle size](https://img.shields.io/badge/brotli-5.9_kb-blue) ![types](https://img.shields.io/badge/types-JSDoc+d.ts-blue) ![license](https://img.shields.io/badge/license-MIT-green) # Symbiote.js <img src="https://rnd-pro.com/svg/symbiote/index.svg" width="200" alt="Symbiote.js"> A lightweight, standards-first UI library built on Web Components. No virtual DOM, no compiler, no build step required - works directly in the browser. A bundler is recommended for production performance, but entirely optional. **~6kb** brotli / **~7kb** gzip. Symbiote.js gives you the convenience of a modern framework while staying close to the native platform - HTML, CSS, and DOM APIs. Components are real custom elements that work everywhere: in any framework, in plain HTML, or in a micro-frontend architecture. And with **isomorphic mode**, the same component code works on the server and the client - server-rendered pages hydrate automatically, no diffing, no mismatch errors. ## What's new in v3 - **Server-Side Rendering** - render components to HTML with `SSR.processHtml()` or stream chunks with `SSR.renderToStream()`. Client-side hydration via `ssrMode` attaches bindings to existing DOM without re-rendering. - **Isomorphic components** - `isoMode` flag makes components work in both SSR and client-only scenarios automatically. If server-rendered content exists, it hydrates; otherwise it renders the template from scratch. One component, zero conditional logic. - **Computed properties** - reactive derived state with microtask batching. - **Path-based router** - optional `AppRouter` module with `:param` extraction, route guards, and lazy loading. - **Exit animations** - `animateOut(el)` for CSS-driven exit transitions, integrated into itemize API. - **Dev mode** - `Symbiote.devMode` enables verbose warnings; import `devMessages.js` for full human-readable messages. - **DSD hydration** - `ssrMode` supports both light DOM and Declarative Shadow DOM. - **Class property fallback** - binding keys not in `init$` fall back to own class properties/methods. - **Lazy mode** - `lazyMode` flag defers component initialization and rendering based on viewport visibility. Can also be enabled via the `lazy` attribute on `itemize` containers to efficiently handle massive data sets. - And [more](https://github.com/symbiotejs/symbiote.js/blob/main/CHANGELOG.md). ## Quick start No install needed - run this directly in a browser: ```html <script type="module"> import Symbiote, { html } from 'https://esm.run/@symbiotejs/symbiote'; class MyCounter extends Symbiote { count = 0; increment() { this.$.count++; } } MyCounter.template = html` <h2>{{count}}</h2> <button ${{onclick: 'increment'}}>Click me!</button> `; MyCounter.reg('my-counter'); </script> <my-counter></my-counter> ``` Or install via npm: ```bash npm i @symbiotejs/symbiote ``` ```js import Symbiote, { html, css } from '@symbiotejs/symbiote'; ``` ## Isomorphic Web Components One component. Server-rendered or client-rendered - automatically. Set `isoMode = true` and the component figures it out: if server-rendered content exists, it hydrates; otherwise it renders from template. No conditional logic, no separate server/client versions: ```js class MyComponent extends Symbiote { isoMode = true; count = 0; increment() { this.$.count++; } } MyComponent.template = html` <h2 ${{textContent: 'count'}}></h2> <button ${{onclick: 'increment'}}>Click me!</button> `; MyComponent.reg('my-component'); ``` This exact code runs **everywhere** - SSR on the server, hydration on the client, or pure client rendering. No framework split, no `'use client'` directives, no hydration mismatch errors. ### SSR - one class, zero config Server rendering doesn't need a virtual DOM, a reconciler, or framework-specific packages: ```js import { SSR } from '@symbiotejs/symbiote/node/SSR.js'; await SSR.init(); // patches globals with linkedom await import('./my-app.js'); // components register normally let html = await SSR.processHtml('<my-app></my-app>'); SSR.destroy(); ``` For large pages, stream HTML chunks with `SSR.renderToStream()` for faster TTFB. See [SSR docs](./docs/ssr.md) and [server setup recipes](./docs/ssr-server.md). ### How it compares | | **Symbiote.js** | **Next.js (React)** | **Lit** (`@lit-labs/ssr`) | |--|----------------|---------------------|----| | **Isomorphic code** | Same code, `isoMode` auto-detects | Server Components vs Client Components split | Same code, but load-order constraints | | **Hydration** | Binding-based - attaches to existing DOM, no diffing | `hydrateRoot()` - must produce identical output or errors | Requires `ssr-client` + hydrate support module | | **Packages** | 1 module + `linkedom` peer dep | Full framework buy-in | 3 packages: `ssr`, `ssr-client`, `ssr-dom-shim` | | **Streaming** | `renderToStream()` async generator | `renderToPipeableStream()` | Iterable `RenderResult` | | **Mismatch handling** | Not needed - bindings attach to whatever DOM exists | Hard errors if server/client output differs | N/A | | **Template output** | Clean HTML with `bind=` attributes | HTML with framework markers | HTML with `<!--lit-part-->` comment markers | | **Lock-in** | None - standard Web Components | Full framework commitment | Lit-specific, but Web Components | **Key insight:** There are no hydration mismatches because there's no diffing. The server produces HTML with binding attributes. The client reads those attributes and adds reactivity. That's it. ## Core concepts ### Reactive state ```js class TodoItem extends Symbiote { text = ''; done = false; toggle() { this.$.done = !this.$.done; } } TodoItem.template = html` <span ${{onclick: 'toggle'}}>{{text}}</span> `; ``` State changes update the DOM synchronously. No virtual DOM, no scheduling, no surprises. And since components are real DOM elements, state is accessible from the outside via standard APIs: ```js document.querySelector('my-counter').$.count = 42; ``` This makes it easy to control Symbiote-based widgets and microfrontends from any host application - no framework adapters, just DOM. ### Templates Templates are plain HTML strings - context-free, easy to test, easy to move between files: ```js // Separate file: my-component.template.js import { html } from '@symbiotejs/symbiote'; export default html` <h1>{{title}}</h1> <button ${{onclick: 'doSomething'}}>Go</button> `; ``` The `html` function supports two interpolation modes: - **Object** → reactive binding: `${{onclick: 'handler'}}` - **String/number** → native concatenation: `${pageTitle}` ### Itemize (dynamic reactive lists) Render lists from data arrays with efficient updates: ```js class TaskList extends Symbiote { tasks = [ { name: 'Buy groceries' }, { name: 'Write docs' }, ]; init$ = { // Needs to be defined in init$ for pop-up binding to work onItemClick: () => { console.log('clicked!'); }, } } TaskList.template = html` <div itemize="tasks"> <template> <div ${{onclick: '^onItemClick'}}>{{name}}</div> </template> </div> `; ``` Items have their own state scope. Use the **`^` prefix** to reach higher-level component properties and handlers - `'^onItemClick'` binds to the parent's `onItemClick`, not the item's. Properties referenced via `^` must be defined in the parent's `init$`. > **Performance Tip:** For massive lists, add the `lazy` attribute to the container (`<div itemize="tasks" lazy>`). It defers component initialization until they enter the viewport and cleans them up when they leave, heavily optimizing memory and rendering performance. ### Pop-up binding (`^`) The `^` prefix works in any nested component template - it walks up the DOM tree to find the nearest ancestor that has the property registered in its data context (`init$` or `add$()`): ```html <!-- Text binding to parent property: --> <div>{{^parentTitle}}</div> <!-- Handler binding to parent method: --> <button ${{onclick: '^parentHandler'}}>Click</button> ``` > **Note:** Class property fallbacks are not checked by the `^` walk - the parent must define the property in `init$`. ### Named data contexts Share state across components without prop drilling: ```js import { PubSub } from '@symbiotejs/symbiote'; PubSub.registerCtx({ user: 'Alex', theme: 'dark', }, 'APP'); // Any component can read/write: this.$['APP/user'] = 'New name'; ``` ### Shared context (`*`) Inspired by native HTML `name` attributes - like how `<input name="group">` groups radio buttons - the `ctx` attribute groups components into a shared data context. Components with the same `ctx` value share `*`-prefixed properties: ```html <upload-btn ctx="gallery"></upload-btn> <file-list ctx="gallery"></file-list> <status-bar ctx="gallery"></status-bar> ``` ```js class UploadBtn extends Symbiote { init$ = { '*files': [] } onUpload() { this.$['*files'] = [...this.$['*files'], newFile]; } } class FileList extends Symbiote { init$ = { '*files': [] } } class StatusBar extends Symbiote { init$ = { '*files': [] } } ``` All three components access the same `*files` state - no parent component, no prop drilling, no global store boilerplate. Just set `ctx="gallery"` in HTML and use `*`-prefixed properties. This makes it trivial to build complex component relationships purely in markup, with ready-made components that don't need to know about each other. The context name can also be inherited via CSS custom property `--ctx`, enabling layout-driven grouping. ### Routing (optional module) ```js import { AppRouter } from '@symbiotejs/symbiote/core/AppRouter.js'; AppRouter.initRoutingCtx('R', { home: { pattern: '/' }, profile: { pattern: '/user/:id' }, about: { pattern: '/about', lazyComponent: () => import('./about.js') }, }); ``` ### Exit animations CSS-driven transitions with zero JS animation code: ```css task-item { opacity: 1; transition: opacity 0.3s; @starting-style { opacity: 0; } /* enter */ &[leaving] { opacity: 0; } /* exit */ } ``` `animateOut(el)` sets `[leaving]`, waits for `transitionend`, then removes. Itemize uses this automatically. ### Styling Shadow DOM is **optional** in Symbiote - use it when you need isolation, skip it when you don't. This gives full flexibility: **Light DOM** - style components with regular CSS, no barriers: ```js MyComponent.rootStyles = css` my-component { display: flex; gap: 1rem; & button { color: var(--accent); } } `; ``` **Shadow DOM** - opt-in isolation when needed: ```js class Isolated extends Symbiote {} Isolated.shadowStyles = css` :host { display: block; } ::slotted(*) { margin: 0; } `; ``` All native CSS features work as expected: CSS variables flow through shadow boundaries, `::part()` exposes internals, modern nesting, `@layer`, `@container` - no framework abstractions in the way. Mix light DOM and shadow DOM components freely in the same app. ### CSS Data Binding Components can read CSS custom properties as reactive state via `cssInit$`: ```css my-widget { --label: 'Click me'; } ``` ```js class MyWidget extends Symbiote {...} MyWidget.template = html` <span>{{--label}}</span> `; ``` CSS values are parsed automatically - quoted strings become strings, numbers become numbers. Call `this.updateCssData()` to re-read after runtime CSS changes. This enables CSS-driven configuration: theme values, layout parameters, or localized strings - all settable from CSS without touching JS. ## Best for - **Complex widgets** embedded in any host application - **Micro frontends** - standard custom elements, no framework coupling - **Reusable component libraries** - works in React, Vue, Angular, or plain HTML - **SSR-powered apps** - lightweight server rendering without framework lock-in - **Framework-agnostic solutions** - one codebase, any context ## Bundle size | Library | Minified | Gzip | Brotli | |---------|----------|------|--------| | **Symbiote.js** (core) | 18.9 kb | 6.6 kb | **5.9 kb** | | **Symbiote.js** (full, with AppRouter) | 23.2 kb | 7.9 kb | **7.2 kb** | | **Lit** 3.3 | 15.5 kb | 6.0 kb | **~5.1 kb** | | **React 19 + ReactDOM** | ~186 kb | ~59 kb | **~50 kb** | Symbiote and Lit have similar base sizes, but Symbiote's **5.9 kb** core includes more built-in features: global state management, lists (itemize API), exit animations, computed properties etc. Lit needs additional packages for comparable features. React is **~8× larger** before adding a router, state manager, or SSR framework. ## Browser support All modern browsers: Chrome, Firefox, Safari, Edge, Opera. ## Docs & Examples - [Documentation](https://github.com/symbiotejs/symbiote.js/blob/main/docs/README.md) - [Lit vs Symbiote.js](https://github.com/symbiotejs/symbiote.js/blob/main/docs/lit-vs-symbiote.md) - Side-by-side comparison - [Live Examples](https://rnd-pro.com/symbiote/3x/examples/) - Interactive Code Playground - [JSDA-Kit](https://github.com/rnd-pro/jsda-kit) - All-in-one companion tool: server, SSG, bundling, import maps, and native Symbiote.js SSR integration - [AI Reference](https://github.com/symbiotejs/symbiote.js/blob/main/AI_REFERENCE.md) - [Changelog](https://github.com/symbiotejs/symbiote.js/blob/main/CHANGELOG.md) **Questions or proposals? Welcome to [Symbiote Discussions](https://github.com/symbiotejs/symbiote.js/discussions)!** ❤️ --- © [rnd-pro.com](https://rnd-pro.com) - MIT License