Skip to content

Migração e Integração

Este guia cobre como adotar Slash em diferentes contextos: migrando de outras bibliotecas, integrando com projetos existentes e configurando ferramentas de build.


Terminal window
bun add @_bashell/slash

A 0.0.3 torna o core seguro por padrão. Uma string é sempre texto, URLs e handlers são verificados e marcação confiável precisa de um tipo explícito. Isso traz mudanças incompatíveis: use a tabela abaixo para achar e corrigir cada caso. A explicação completa está em Segurança.

Se o seu código…O que acontece agoraComo migrar
usa htmlString`...` como string (.length, +, .startsWith, res.send(x))ele devolve um SafeHtmluse String(x) ou renderToString(() => x).html
devolve HTML montado à mão de um componente ou helper (return "<div>...</div>", Markdown, ícones)a string aparece como texto visíveldevolva um template htmlString/html; para marcação confiável use unsafeHtml(str)
passa innerHTML=${...}, outerHTML=... ou srcdoc=${...} como propa prop é ignorada (aviso em dev)use unsafeHtml(...) como filho; srcdoc=${unsafeHtml(...)}
usa javascript:, data:text/html, file:, whatsapp:, ftp: ou outros esquemas próprios em href/src/action (passam http, https, mailto, tel, sms; blob: em src de mídia; data:image/svg+xml em img e url() de CSS)o valor vira about:blank#blockeduse uma URL real, ou unsafeUrl(url) para uma URL confiável; valide entradas com sanitizeUrl
usa <${Link} to="./x">, to="../x", to="about" ou uma URL absolutanão navega, href bloqueadouse um caminho do app (/x, ?q, #h); para site externo, adicione external
usa handlers em string (onclick="...") ou um atributo comum que comece com ontoda prop on* que não seja função, objeto handler ou [fn, opções] é descartada (cliente e SSR)passe uma função: onClick=${fn}; use data- nos atributos comuns
escreve <script>${json}</script> ou <style>${css}</style> com valores dinâmicos (html ou htmlString)strings, números, arrays, componentes e reativos dinâmicos são descartados, no cliente e no SSR (aviso em dev)unsafeHtml(serializeStateForScript(dados)) para JSON; mantenha CSS/JS estáticos
coloca um < literal num <script> estático dentro do htmlString (if (a < b))o htm lê como tagmova esse código para unsafeHtml(...)
usa <script id="__SLASH_STATE__"> sem typeo render() ignora o script (aviso em dev)adicione type="application/json"
passa props com nome de método do DOM (click, focus) ou constructora prop de método vira atributo e constructor é bloqueadause onClick=${fn} para eventos
tem estado circular ou aninhado em mais de 1000 níveiscreateState lança State is circular or nested deeper than 1000 levelsachate o estado ou guarde ids em vez de referências
compara mensagens de runtime em português (URL bloqueada…) em testesas mensagens agora são em inglêsatualize o texto esperado
depende de o roteador interceptar links mailto:, tel: ou de outra origem, ou de ?q/#h relativos à rotasó links http(s) da mesma origem são interceptados; ?q e #h são relativos à página atuallinks normais não mudam; use external para outros sites
interpola um State direto no SSR (${state})um State não é reativo no servidorinterpole state.get()
embute estado com JSON.stringify(state) num <script>quebra com </script>serializeStateForScript(state)
chama __addBatchEndCallback, __removeBatchEndCallback ou __recordBatchUpdateremovidosuse batch() e state.watch()
monta um RouterInstance à mão (mock)ready é obrigatórioadicione ready: Promise.resolve()
chama data.hasOwnProperty(...) em formToObject() ou router.queryesses objetos não têm protótipoObject.hasOwn(data, "campo")
usava strings style="..." no cliente aplicadas como vieramdeclarações inseguras são descartadasuse só valores CSS simples: sem comentários /* */, escapes só dentro de aspas, url("a b.png") com aspas se houver caracteres especiais, no máximo 8 KB

Quanto aos builds: Vite (dev) e webpack (mode: "development") usam sozinhos o build de desenvolvimento, que mostra um aviso para cada bloqueio. O build de produção não traz esses avisos, mas mantém console.error para erros reais. Para ver os avisos fora de um bundler, rode com --conditions=development.

Buscas rápidas para achar os casos no seu projeto:

Terminal window
grep -rnE "htmlString|innerHTML=|srcdoc=|href=\$\{|onclick=|JSON.stringify\(state" src

ReactSlashExemplo
useState()createState()const [count, setCount] = useState(0) → const count = createState(0)
useEffect()state.watch() ou código diretoVer exemplos abaixo
useMemo()Funções normaisSem cache automático; calcule a partir de state.get()
useCallback()Funções normaisNão necessário
useRef()Variável fora do componenteO componente re-executa, então variáveis locais são recriadas
useContext() / useState()Estado fora do componentecreateState() no módulo; não há estado local por instância
propsParâmetros de funçãoIdêntico
JSXhtml template tagVer exemplos
dangerouslySetInnerHTMLunsafeHtml(...) como filhoinnerHTML é bloqueado como prop; unsafeHtml não sanitiza (veja Segurança)
// React
import { useState } from 'react'
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)}>
Decrement
</button>
</div>
)
}
// Slash
import { html, createState, type State } from '@_bashell/slash'
interface CounterProps {
count: State<number> // o state é criado fora e passado por props
}
// Monte como <${Counter} count=${count} />
function Counter({ count }: CounterProps) {
return html`
<div>
<p>Count: ${count.get()}</p>
<button onClick=${() => count.set(count.get() + 1)}>
Increment
</button>
<button onClick=${() => count.set(count.get() - 1)}>
Decrement
</button>
</div>
`
}
const count = createState(0) // valor inicial = o initialCount do React

Você pode migrar componente por componente sem reescrever tudo:

  1. Identifique componentes folha (sem filhos React)
  2. Migre de baixo para cima
  3. Use micro-frontends se necessário
// React component que usa Slash component
import { useEffect, useRef } from 'react'
import { html, render } from '@_bashell/slash'
import { SlashComponent } from './slash-components'
function ReactWrapper() {
const containerRef = useRef(null)
useEffect(() => {
if (containerRef.current) {
render(html`<${SlashComponent} />`, containerRef.current)
}
}, [])
return <div ref={containerRef} />
}

Vue 3SlashNotas
ref()createState().value vs .get()/.set()
computed()FunçõesCompute on-demand
watch()state.watch()API similar
v-ifTernário ? :JS nativo
v-for.map()JS nativo
v-modelonInput + state.set() (ou textFieldControl)Manual binding
onMounted()Código fora do componenteO corpo do componente re-executa; não há hook de montagem
onUnmounted()Sem equivalente públicoGerencie timers/listeners você mesmo
Templatehtml tagHTM syntax
v-htmlunsafeHtml(...) como filhoSó para marcação confiável e sanitizada
<!-- Vue 3 -->
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() {
count.value++
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">+</button>
</div>
</template>

<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="app"></div>
<script type="module">
import { html, render, createState } from 'https://esm.sh/@_bashell/slash'
// O state fica fora do componente
const count = createState(0)
const App = () => html`
<div>
<h1>Hello Slash!</h1>
<p>Count: ${count.get()}</p>
<button onClick=${() => count.set(count.get() + 1)}>
Increment
</button>
</div>
`
// Monte como <${App} /> para o componente ser reativo
render(html`<${App} />`, document.getElementById('app'))
</script>
</body>
</html>

Slash pode conviver com jQuery ou vanilla JS:

import { html, render, createState } from '@_bashell/slash'
// Estado Slash
const globalState = createState({
user: null,
isLoggedIn: false
})
// Component Slash
function UserWidget() {
const state = globalState.get()
if (!state.isLoggedIn) {
return html`<button onClick=${showLoginModal}>Login</button>`
}
return html`<p>Welcome, ${state.user.name}!</p>`
}
// Integração com jQuery
function showLoginModal() {
$('#loginModal').modal('show')
}
$('#loginForm').on('submit', function(e) {
e.preventDefault()
// Atualiza estado Slash
globalState.set({
user: { name: 'John' },
isLoggedIn: true
})
$('#loginModal').modal('hide')
})
// Render componente Slash
// Monte como <${UserWidget} />: ele lê globalState e re-renderiza quando o state muda
render(html`<${UserWidget} />`, document.getElementById('user-widget'))

Encapsule Slash em Web Components:

import { html, render, createState } from '@_bashell/slash'
class CounterElement extends HTMLElement {
connectedCallback() {
// connectedCallback roda de novo se o elemento for movido ou reinserido:
// não chame attachShadow (lança erro) nem monte duas vezes
if (this.shadowRoot) return
const shadow = this.attachShadow({ mode: 'open' })
// O state vive na instância, fora do componente
const count = createState(0)
const Counter = () => html`
<div>
<p>Count: ${count.get()}</p>
<button onClick=${() => count.set(count.get() + 1)}>
Increment
</button>
</div>
`
// render() exige um Element como container (ShadowRoot não serve)
const mount = document.createElement('div')
shadow.appendChild(mount)
render(html`<${Counter} />`, mount)
}
}
customElements.define('slash-counter', CounterElement)

Uso:

<slash-counter></slash-counter>

Terminal window
bun create vite my-app --template vanilla-ts
cd my-app
bun add @_bashell/slash
src/main.ts
import { html, render, createState } from '@_bashell/slash'
import './style.css'
const count = createState(0)
const App = () => html`
<div>
<h1>Vite + Slash</h1>
<p>Count: ${count.get()}</p>
<button onClick=${() => count.set(count.get() + 1)}>
Increment
</button>
</div>
`
const root = document.querySelector('#app')
if (root) {
render(html`<${App} />`, root)
}
vite.config.js
import { defineConfig } from 'vite'
export default defineConfig({
// Configuração padrão funciona!
})
Terminal window
bun init
bun add @_bashell/slash
index.ts
import { html, render, createState } from '@_bashell/slash'
const count = createState(0)
const App = () => html`
<div>
<h1>Bun + Slash</h1>
<button onClick=${() => count.set(count.get() + 1)}>
Count: ${count.get()}
</button>
</div>
`
render(html`<${App} />`, document.body)
package.json
{
"scripts": {
"dev": "bun run --watch index.ts",
"build": "bun build index.ts --outdir ./dist --minify"
}
}
Terminal window
npm install --save-dev webpack webpack-cli webpack-dev-server
npm install @_bashell/slash
webpack.config.js
const path = require('path')
module.exports = {
entry: './src/index.ts',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
},
resolve: {
extensions: ['.ts', '.js']
},
devServer: {
static: './dist'
}
}
Terminal window
npm install --save-dev parcel
npm install @_bashell/slash
index.html
<!DOCTYPE html>
<html>
<head>
<title>Parcel + Slash</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/index.ts"></script>
</body>
</html>
package.json
{
"scripts": {
"dev": "parcel index.html",
"build": "parcel build index.html"
}
}
Terminal window
npm install --save-dev esbuild
npm install @_bashell/slash
build.js
require('esbuild').build({
entryPoints: ['src/index.ts'],
bundle: true,
outfile: 'dist/bundle.js',
minify: true,
sourcemap: true
}).catch(() => process.exit(1))
package.json
{
"scripts": {
"build": "node build.js"
}
}

tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"types": ["vite/client"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Terminal window
bun add express @_bashell/slash
bun add -d @types/express
server.ts
import express from 'express'
import { renderToString, serializeStateForScript } from '@_bashell/slash/ssr'
import { App } from './App' // App usa htmlString no servidor
const app = express()
app.get('*', (req, res) => {
// renderToString devolve { html, state }
const { html: appHtml, state } = renderToString(() => App())
// O documento é um template literal comum (htmlString com <!DOCTYPE> não funciona).
// O título é fixo aqui; se vier de dado de usuário, escape-o à mão.
const html = `<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
<script type="module" src="/client.js"></script>
</head>
<body>
<div id="app">${appHtml}</div>
<script id="__SLASH_STATE__" type="application/json">${serializeStateForScript(state)}</script>
</body>
</html>`
res.send(html)
})
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
})
server.ts
import { renderToString, serializeStateForScript } from '@_bashell/slash/ssr'
import { App } from './App' // App usa htmlString no servidor
Bun.serve({
port: 3000,
fetch(req) {
const { html: appHtml, state } = renderToString(() => App())
// Template literal comum para o documento (não use htmlString com <!DOCTYPE>)
const html = `<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script id="__SLASH_STATE__" type="application/json">${serializeStateForScript(state)}</script>
<script type="module" src="/client.js"></script>
</body>
</html>`
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
})
}
})

my-app/
├── src/
│ ├── components/
│ │ ├── Header.ts
│ │ ├── Footer.ts
│ │ └── TodoItem.ts
│ ├── pages/
│ │ ├── Home.ts
│ │ ├── About.ts
│ │ └── NotFound.ts
│ ├── state/
│ │ └── todoState.ts
│ ├── router.ts
│ ├── App.ts
│ └── main.ts
├── public/
│ └── styles.css
├── index.html
├── package.json
└── tsconfig.json
my-app/
├── src/
│ ├── shared/
│ │ ├── components/
│ │ ├── loaders/
│ │ └── types.ts
│ ├── server/
│ │ ├── index.ts
│ │ └── routes.ts
│ ├── client/
│ │ └── index.ts
│ └── App.ts
├── package.json
└── tsconfig.json

Counter.test.ts
import { test, expect } from 'bun:test'
import { createState } from '@_bashell/slash'
test('counter increments', () => {
const count = createState(0)
expect(count.get()).toBe(0)
count.set(5)
expect(count.get()).toBe(5)
})
test('state watches changes', () => {
const count = createState(0)
let called = false
count.watch((newVal) => {
called = true
expect(newVal).toBe(10)
})
count.set(10)
expect(called).toBe(true)
})
Terminal window
bun add -d vitest happy-dom
vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom'
}
})

Não reescreva tudo de uma vez. Comece com:

  • Componentes novos
  • Componentes folha (sem filhos)
  • Features isoladas

Slash tem tipos excelentes. Use para facilitar migração:

// Defina interfaces para seus dados
interface User {
id: string
name: string
email: string
}
const user = createState<User | null>(null)
src/state/global.ts
import { createState } from '@_bashell/slash'
export const authState = createState({
user: null,
isAuthenticated: false
})
export const uiState = createState({
sidebarOpen: false,
theme: 'light'
})

Chame esses helpers fora dos componentes (no módulo): um createState() dentro de um componente é recriado a cada render.

src/utils/form.ts
import { createState } from '@_bashell/slash'
export function createFormField<T>(initialValue: T) {
const value = createState(initialValue)
const error = createState<string | null>(null)
const touched = createState(false)
return {
value,
error,
touched,
reset: () => {
value.set(initialValue)
error.set(null)
touched.set(false)
}
}
}