Reactivity

Tidos renders HTML on the server. For client-side interactivity, compile any JavaScript framework component as a Web Component and embed it using #[native_element].

How It Works

  1. Write your component in the JS framework of your choice.
  2. Compile it as a custom element to dist/ComponentName.js.
  3. Create a Rust struct with #[native_element] to create a type safe wrapper.
  4. Use it in page! or view! like any other Tidos component.

The Rust Wrapper

#[native_element] derives Component for the struct. It injects the script tag and renders the kebab-case custom element with all fields forwarded as HTML attributes:

1use tidos::native_element;
2
3// GreetUser -> /dist/GreetUser.js -> <greet-user>
4#[native_element]
5pub struct GreetUser {
6 pub name: String,
7 pub is_admin: bool,
8}
9
10// Output of macro
11impl tidos::Component for GreetUser {
12 fn to_render(&self, page: &mut tidos::Page) {
13 tidos::head!{ <script r#type="module" src="/dist/GreetUser.js"></script> };
14 tidos::view!{ <greet-user name={self.name} :is-admin={self.is_admin}></greet-user> };
15 }
16}

Framework Example

1<!-- src/components/GreetUser.svelte -->
2<svelte:options customElement="greet-user"></svelte:options>
3
4<script>
5 let { name = '', is_admin = false } = $props();
6</script>
7
8<div>
9 <p>Hello, {name}!</p>
10 {#if is_admin}
11 <span class="badge">Admin</span>
12 {/if}
13</div>
14
15<style>
16 :host { display: block; }
17 .badge { background: #4fd1c5; color: #000; padding: 0.2rem 0.5rem; border-radius: 4px; }
18</style>
1// src/components/GreetUser.ts
2import { LitElement, html } from 'lit';
3import { customElement, property } from 'lit/decorators.js';
4
5@customElement('greet-user')
6export class GreetUser extends LitElement {
7 @property() name = '';
8 @property({ type: Boolean }) isAdmin = false;
9
10 render() {
11 return html`
12 <p>Hello, ${this.name}!</p>
13 ${this.isAdmin ? html`<span class="badge">Admin</span>` : ''}
14 `;
15 }
16}
1// app.module.ts — register as Angular Element
2import { createCustomElement } from '@angular/elements';
3import { GreetUserComponent } from './greet-user.component';
4
5@NgModule({ ... })
6export class AppModule {
7 constructor(private injector: Injector) {}
8 ngDoBootstrap() {
9 const el = createCustomElement(GreetUserComponent, { injector: this.injector });
10 customElements.define('greet-user', el);
11 }
12}
1<!-- src/components/GreetUser.vue -->
2<template>
3 <div>
4 <p>Hello, {{ name }}!</p>
5 <span v-if="isAdmin" class="badge">Admin</span>
6 </div>
7</template>
8
9<script setup>
10defineProps({
11 name: { type: String, default: '' },
12 isAdmin: { type: Boolean, default: false },
13});
14</script>
15
16<!-- src/components/GreetUser.jswrapper that registers the SFC as a custom element -->
17import { defineCustomElement } from 'vue';
18import GreetUser from './GreetUser.vue';
19
20customElements.define('greet-user', defineCustomElement(GreetUser));
1// src/components/GreetUser.tsx
2import { createRoot } from 'react-dom/client';
3
4class GreetUser extends HTMLElement {
5 connectedCallback() {
6 const name = this.getAttribute('name') || '';
7 const isAdmin = this.hasAttribute('is-admin');
8 const root = createRoot(this);
9 root.render(
10 <div>
11 <p>Hello, {name}!</p>
12 {isAdmin && <span className="badge">Admin</span>}
13 </div>
14 );
15 }
16}
17
18customElements.define('greet-user', GreetUser);

Build Config

Compile the component to dist/ComponentName.js using Vite:

1import {build, createLogger, defineConfig} from 'vite';
2import { svelte } from '@sveltejs/vite-plugin-svelte';
3import { extname, join, basename, resolve } from 'path';
4import { readdirSync, statSync, } from 'fs';
5
6let logger = createLogger('info', { prefix: '[tidos]' })
7
8function getEntries(dir, extension = '.svelte') {
9 const entries = {};
10
11 function walk(currentDir) {
12 const files = readdirSync(currentDir);
13
14 files.forEach((file) => {
15 const fullPath = join(currentDir, file);
16 const stat = statSync(fullPath);
17
18 if (stat.isDirectory()) {
19 // Recurse into subdirectories
20 walk(fullPath);
21 } else if (extname(file) === extension) {
22 // Collect files with the specified extension
23 const name = basename(file, extension);
24 entries[name] = resolve(__dirname, fullPath);
25 }
26 });
27 }
28
29 walk(dir);
30 return entries;
31}
32
33const entries = getEntries('src');
34
35function tidosSvelteHMR() {
36
37 let shouldDebounce = false
38 const hmrBuild = async () => {
39 shouldDebounce = true
40 await build({ logLevel: "silent" })
41 };
42
43 return {
44 name: 'tidos-svelte-hmr',
45 enforce: "pre",
46 // HMR
47 handleHotUpdate: ({ file, server }) => {
48 if (!shouldDebounce) {
49 logger.info(`Changes detected, building new version...`, { timestamp: true })
50 hmrBuild()
51 .then(() => {
52 shouldDebounce = false
53 logger.info(`Build completed.`, { timestamp: true })
54 })
55 }
56 return []
57 }
58 }
59}
60
61export default defineConfig({
62 plugins: [
63 svelte({
64 compilerOptions: {
65 customElement: true,
66 },
67 }),
68 tidosSvelteHMR(),
69 ],
70 build: {
71 rollupOptions: {
72 input: entries,
73 output: {
74 entryFileNames: '[name].js',
75 chunkFileNames: '[name].js',
76 dir: 'dist', // Output directory for the compiled files
77 assetFileNames: '[name][extname]',
78 },
79 },
80 },
81});
1import { build, createLogger, defineConfig } from 'vite';
2import { extname, join, basename, resolve } from 'path';
3import { readdirSync, statSync } from 'fs';
4
5let logger = createLogger('info', { prefix: '[tidos]' });
6
7function getEntries(dir, extension = '.js') {
8 const entries = {};
9
10 function walk(currentDir) {
11 const files = readdirSync(currentDir);
12
13 files.forEach((file) => {
14 const fullPath = join(currentDir, file);
15 const stat = statSync(fullPath);
16
17 if (stat.isDirectory()) {
18 walk(fullPath);
19 } else if (extname(file) === extension && !file.endsWith('.config.js')) {
20 const name = basename(file, extension);
21 entries[name] = resolve(__dirname, fullPath);
22 }
23 });
24 }
25
26 walk(dir);
27 return entries;
28}
29
30const entries = getEntries('src');
31
32function tidosLitHMR() {
33 let shouldDebounce = false;
34 const hmrBuild = async () => {
35 shouldDebounce = true;
36 await build({ logLevel: 'silent' });
37 };
38
39 return {
40 name: 'tidos-lit-hmr',
41 enforce: 'pre',
42 handleHotUpdate: ({ file, server }) => {
43 if (!shouldDebounce) {
44 logger.info(`Changes detected, building new version...`, { timestamp: true });
45 hmrBuild().then(() => {
46 shouldDebounce = false;
47 logger.info(`Build completed.`, { timestamp: true });
48 });
49 }
50 return [];
51 },
52 };
53}
54
55export default defineConfig({
56 plugins: [tidosLitHMR()],
57 build: {
58 rollupOptions: {
59 input: entries,
60 output: {
61 entryFileNames: '[name].js',
62 chunkFileNames: '[name].js',
63 dir: 'dist',
64 assetFileNames: '[name][extname]',
65 },
66 },
67 },
68});
1import { build, createLogger, defineConfig } from 'vite';
2import angular from '@analogjs/vite-plugin-angular';
3import { extname, join, basename, resolve } from 'path';
4import { readdirSync, statSync } from 'fs';
5
6let logger = createLogger('info', { prefix: '[tidos]' });
7
8function getEntries(dir, extension = '.ts') {
9 const entries = {};
10
11 function walk(currentDir) {
12 const files = readdirSync(currentDir);
13
14 files.forEach((file) => {
15 const fullPath = join(currentDir, file);
16 const stat = statSync(fullPath);
17
18 if (stat.isDirectory()) {
19 walk(fullPath);
20 } else if (extname(file) === extension && !file.includes('.config.')) {
21 const name = basename(file, extension);
22 entries[name] = resolve(__dirname, fullPath);
23 }
24 });
25 }
26
27 walk(dir);
28 return entries;
29}
30
31const entries = getEntries('src');
32
33function tidosAngularHMR() {
34 let shouldDebounce = false;
35 const hmrBuild = async () => {
36 shouldDebounce = true;
37 await build({ logLevel: 'silent' });
38 };
39
40 return {
41 name: 'tidos-angular-hmr',
42 enforce: 'pre',
43 handleHotUpdate: ({ file, server }) => {
44 if (!shouldDebounce) {
45 logger.info(`Changes detected, building new version...`, { timestamp: true });
46 hmrBuild().then(() => {
47 shouldDebounce = false;
48 logger.info(`Build completed.`, { timestamp: true });
49 });
50 }
51 return [];
52 },
53 };
54}
55
56export default defineConfig({
57 plugins: [
58 angular(),
59 tidosAngularHMR(),
60 ],
61 build: {
62 rollupOptions: {
63 input: entries,
64 output: {
65 entryFileNames: '[name].js',
66 chunkFileNames: '[name].js',
67 dir: 'dist',
68 assetFileNames: '[name][extname]',
69 },
70 },
71 },
72});
1import { build, createLogger, defineConfig } from 'vite';
2import vue from '@vitejs/plugin-vue';
3import { extname, join, basename, resolve } from 'path';
4import { readdirSync, statSync } from 'fs';
5
6let logger = createLogger('info', { prefix: '[tidos]' });
7
8function getEntries(dir, extension = '.js') {
9 const entries = {};
10
11 function walk(currentDir) {
12 const files = readdirSync(currentDir);
13
14 files.forEach((file) => {
15 const fullPath = join(currentDir, file);
16 const stat = statSync(fullPath);
17
18 if (stat.isDirectory()) {
19 walk(fullPath);
20 } else if (extname(file) === extension && !file.endsWith('.config.js')) {
21 const name = basename(file, extension);
22 entries[name] = resolve(__dirname, fullPath);
23 }
24 });
25 }
26
27 walk(dir);
28 return entries;
29}
30
31const entries = getEntries('src');
32
33function tidosVueHMR() {
34 let shouldDebounce = false;
35 const hmrBuild = async () => {
36 shouldDebounce = true;
37 await build({ logLevel: 'silent' });
38 };
39
40 return {
41 name: 'tidos-vue-hmr',
42 enforce: 'pre',
43 handleHotUpdate: ({ file, server }) => {
44 if (!shouldDebounce) {
45 logger.info(`Changes detected, building new version...`, { timestamp: true });
46 hmrBuild().then(() => {
47 shouldDebounce = false;
48 logger.info(`Build completed.`, { timestamp: true });
49 });
50 }
51 return [];
52 },
53 };
54}
55
56export default defineConfig({
57 plugins: [
58 vue(),
59 tidosVueHMR(),
60 ],
61 build: {
62 rollupOptions: {
63 input: entries,
64 output: {
65 entryFileNames: '[name].js',
66 chunkFileNames: '[name].js',
67 dir: 'dist',
68 assetFileNames: '[name][extname]',
69 },
70 },
71 },
72});
1import { build, createLogger, defineConfig } from 'vite';
2import react from '@vitejs/plugin-react';
3import { extname, join, basename, resolve } from 'path';
4import { readdirSync, statSync } from 'fs';
5
6let logger = createLogger('info', { prefix: '[tidos]' });
7
8function getEntries(dir, extension = '.js') {
9 const entries = {};
10
11 function walk(currentDir) {
12 const files = readdirSync(currentDir);
13
14 files.forEach((file) => {
15 const fullPath = join(currentDir, file);
16 const stat = statSync(fullPath);
17
18 if (stat.isDirectory()) {
19 walk(fullPath);
20 } else if (extname(file) === extension && !file.endsWith('.config.js')) {
21 const name = basename(file, extension);
22 entries[name] = resolve(__dirname, fullPath);
23 }
24 });
25 }
26
27 walk(dir);
28 return entries;
29}
30
31const entries = getEntries('src');
32
33function tidosReactHMR() {
34 let shouldDebounce = false;
35 const hmrBuild = async () => {
36 shouldDebounce = true;
37 await build({ logLevel: 'silent' });
38 };
39
40 return {
41 name: 'tidos-react-hmr',
42 enforce: 'pre',
43 handleHotUpdate: ({ file, server }) => {
44 if (!shouldDebounce) {
45 logger.info(`Changes detected, building new version...`, { timestamp: true });
46 hmrBuild().then(() => {
47 shouldDebounce = false;
48 logger.info(`Build completed.`, { timestamp: true });
49 });
50 }
51 return [];
52 },
53 };
54}
55
56export default defineConfig({
57 plugins: [
58 react(),
59 tidosReactHMR(),
60 ],
61 build: {
62 rollupOptions: {
63 input: entries,
64 output: {
65 entryFileNames: '[name].js',
66 chunkFileNames: '[name].js',
67 dir: 'dist',
68 assetFileNames: '[name][extname]',
69 },
70 },
71 },
72});

Serving Built Files

Add a route in your Rust server to serve files from the dist directory:

1// Rocket — serve compiled JS from the dist folder
2#[get("/dist/<file..>")]
3async fn dist_files(file: std::path::PathBuf) -> Option<rocket::fs::NamedFile> {
4 rocket::fs::NamedFile::open(std::path::Path::new("dist/").join(file)).await.ok()
5}