Notas no armazenamento do dispositivo 📝¶
Construa um CRUD de notas completo persistido no IndexedDB do browser via tempestweb.native.storage — e aprenda como injetar capacidades nativas no estado para que sua UI seja 100 % testável sem browser.
O que você vai construir¶
Um gerenciador de notas com:
- ✏️ Compositor — campos de título e corpo + botões "Save" e "Reload list"
- 📋 Lista de notas — uma linha por nota salva com botões "Open" e "Delete"
- 📖 Painel de visualização — exibe título e conteúdo da nota aberta
- ⚠️ Faixa de erro — texto vermelho quando uma operação falha
- 🔄 Indicador de progresso —
Spinnerdurante save e reload
As quatro operações de storage (put / get / list_keys / remove) são injetadas no estado do app — você pode substituí-las por doubles de teste sem instalar nenhuma ponte real.
Tema — Capacidades nativas (Track N)
Este exemplo faz parte do tema Native capabilities. As capacidades nativas são APIs do browser (IndexedDB, geolocalização, câmera, etc.) expostas ao Python como awaitables tipados por tempestweb.native. A mesma chamada Python funciona nos dois modos de execução — WASM ou servidor.
Pré-requisitos¶
Certifique-se de ter o tempestweb instalado:
Leitura recomendada (opcional, mas útil):
- Tutorial básico — primeiros passos com
App,vieweset_state - Gerenciando estado — como
set_statefunciona - Modos de execução — WASM vs. servidor
- Capacidades nativas — o que são pontes e por que são necessárias
Criando o projeto¶
Passo 1 — Por que os callables de storage ficam no estado?¶
Antes de escrever qualquer widget, vale entender o padrão de design central deste exemplo.
As funções storage.put, storage.get, etc. só funcionam com uma ponte nativa instalada. Durante o render inicial (o build(view(app)) que o framework chama na montagem) nenhuma operação de I/O é executada — apenas a árvore de widgets é construída. A ponte não precisa estar presente nesse momento.
Capacidades nativas exigem uma ponte
Chamar await storage.put(...) (ou qualquer outra capacidade nativa) sem uma ponte instalada levanta BrowserUnavailableError. A ponte é instalada automaticamente pelo runtime:
- Modo A (WASM): o bootstrap instala uma
FFIBridgeque despacha paraclient/native/*.jsvia FFI do Pyodide — sem round-trip de rede. - Modo B (servidor): o servidor instala uma
ProxyBridgeque serializa a chamada para o envelopenative_call, envia pelo WebSocket, aguarda onative_resultque o browser devolve.
Em testes unitários — sem browser nem servidor — instale um fake bridge com install_bridge(fake) antes de acionar handlers async, e remova-o com uninstall_bridge() depois. O render inicial (construção da árvore de widgets) jamais chama os callables — não precisa de bridge.
A solução é colocar os callables como campos do estado com os valores reais como default. Assim:
- O
build(view(app))inicial não toca I/O e funciona sem bridge. - Os handlers async leem
app.state.put,app.state.getetc. — e em testes você sobrescreve esses campos com fakes antes de acionar o handler.
from collections.abc import Awaitable, Callable
from tempestweb.native import storage
Putter = Callable[[str, str], Awaitable[None]]
Getter = Callable[[str], Awaitable[str]]
Remover = Callable[[str], Awaitable[None]]
KeyLister = Callable[[], Awaitable[list[str]]]
Dica — aliases de tipo para callables injetados
Nomear os tipos de callable (Putter, Getter, etc.) cumpre dois objetivos: o mypy --strict passa sem reclamações e a intenção fica documentada no campo do dataclass.
Passo 2 — O estado da aplicação¶
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class State:
"""Application state for the notes storage demo.
Attributes:
title_draft: The title currently typed in the title field.
body_draft: The body currently typed in the body field.
keys: The list of saved note keys fetched from storage.
open_key: The key of the note currently open for reading, or ``""``.
open_content: The content of the open note, or ``""``.
saving: ``True`` while a save operation is in flight.
loading: ``True`` while a list or open operation is in flight.
error: Last error message, or ``""`` when there is no error.
put: Injected callable matching :func:`~tempestweb.native.storage.put`.
get: Injected callable matching :func:`~tempestweb.native.storage.get`.
remove: Injected callable matching
:func:`~tempestweb.native.storage.remove`.
list_keys: Injected callable matching
:func:`~tempestweb.native.storage.list_keys`.
"""
title_draft: str = ""
body_draft: str = ""
keys: list[str] = field(default_factory=list)
open_key: str = ""
open_content: str = ""
saving: bool = False
loading: bool = False
error: str = ""
# Injected capabilities — real implementations by default; only called
# inside async handlers, never during the initial mount/build.
put: Putter = field(default=storage.put)
get: Getter = field(default=storage.get)
remove: Remover = field(default=storage.remove)
list_keys: KeyLister = field(default=storage.list_keys)
def make_state() -> State:
"""Return the initial, blank application state.
Returns:
A fresh :class:`State` ready for the first render.
"""
return State()
Nota — field(default=storage.put) vs. field(default_factory=...)
Como storage.put é uma função (não um objeto mutável como uma lista), podemos usá-la diretamente como default= em vez de default_factory=. O dataclass armazenará uma referência para a função — que é exatamente o comportamento desejado.
Passo 3 — O handler de salvar¶
Dentro de view() definimos os handlers como funções aninhadas. O handler de save é assíncrono porque chama await app.state.put(...):
async def save_note() -> None:
"""Persist the current draft to storage under ``title_draft``."""
title = app.state.title_draft.strip()
body = app.state.body_draft
if not title:
return
app.set_state(lambda s: setattr(s, "saving", True))
try:
await app.state.put(title, body)
def _on_saved(s: State) -> None:
s.saving = False
s.title_draft = ""
s.body_draft = ""
s.error = ""
app.set_state(_on_saved)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_save_error(s: State) -> None:
s.saving = False
s.error = msg
app.set_state(_on_save_error)
Dica — otimismo + rollback
O handler ativa saving=True antes do await para dar feedback imediato. Nos dois ramos seguintes (_on_saved e _on_save_error) ele desativa saving=False. Esse padrão "otimista + rollback" evita que a UI fique travada se o await for cancelado antes de completar.
Aviso — except Exception as exc dentro de handlers
Capacidades nativas levantam NativeError (quota excedida, not_found, etc.) e BrowserUnavailableError (bridge não instalada). Capturar Exception genérico aqui é intencional: a UI deve mostrar o erro para o usuário em vez de explodir. O comentário # noqa: BLE001 suprime o aviso de broad-exception-caught do ruff.
Passo 4 — O handler de listar¶
async def refresh_list() -> None:
"""Fetch all stored note keys and update the list."""
app.set_state(lambda s: setattr(s, "loading", True))
try:
keys = await app.state.list_keys()
def _on_keys(s: State) -> None:
s.loading = False
s.keys = keys
s.error = ""
app.set_state(_on_keys)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_list_error(s: State) -> None:
s.loading = False
s.error = msg
app.set_state(_on_list_error)
Nota — list_keys nunca levanta NotFoundError
storage.list_keys() retorna [] quando o armazenamento está vazio — jamais levanta exceção de "não encontrado". Isso segue a convenção do framework: 404 só para lookups de recurso único; coleções retornam [].
Passo 5 — Os handlers de abrir e excluir¶
Como Open e Delete precisam saber qual nota estão operando, eles usam o padrão factory de handler: uma função síncrona recebe a chave e retorna um callable async sem parâmetros adequado para Button.on_click.
def open_note_handler(note_key: str) -> Callable[[], Awaitable[None]]:
"""Return an async click handler that opens *note_key*.
Args:
note_key: The storage key to open.
Returns:
A parameterless async callable suitable for ``Button.on_click``.
"""
async def _open() -> None:
app.set_state(lambda s: setattr(s, "loading", True))
try:
content = await app.state.get(note_key)
def _on_open(s: State) -> None:
s.loading = False
s.open_key = note_key
s.open_content = content
s.error = ""
app.set_state(_on_open)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_open_error(s: State) -> None:
s.loading = False
s.error = msg
app.set_state(_on_open_error)
return _open
def delete_note_handler(note_key: str) -> Callable[[], Awaitable[None]]:
"""Return an async click handler that deletes *note_key*.
Args:
note_key: The storage key to delete.
Returns:
A parameterless async callable suitable for ``Button.on_click``.
"""
async def _delete() -> None:
try:
await app.state.remove(note_key)
# Also remove from local list immediately for a snappy UI.
def _on_delete(s: State) -> None:
s.keys = [k for k in s.keys if k != note_key]
if s.open_key == note_key:
s.open_key = ""
s.open_content = ""
s.error = ""
app.set_state(_on_delete)
except Exception as exc: # noqa: BLE001
msg = str(exc)
app.set_state(lambda s: setattr(s, "error", msg))
return _delete
Dica — remoção otimista da lista local em _delete
Depois que await app.state.remove(note_key) retorna com sucesso, o handler filtra app.state.keys imediatamente — sem fazer um novo round-trip de list_keys. Isso deixa a UI responsiva: o item desaparece na hora. Um Reload list posterior sincroniza com o estado real do IndexedDB.
Dica — factory de handler vs. lambda _k=key: ...
Você poderia usar lambda _k=note_key: _open_impl(_k) dentro do loop para capturar o valor atual, mas a factory open_note_handler(key) é mais legível e permite anotar o tipo de retorno com precisão — o que o mypy --strict exige.
Passo 6 — O handler de fechar¶
def close_note() -> None:
"""Clear the open-note panel."""
def _close(s: State) -> None:
s.open_key = ""
s.open_content = ""
app.set_state(_close)
Este handler é síncrono — não acessa storage, apenas limpa dois campos de estado.
Passo 7 — Montando a UI¶
A view monta três seções em colunas empilhadas na raiz. Veja cada seção:
Compositor¶
composer_children: list[Widget] = [
Text(content="New note", key="composer-title"),
Input(
value=app.state.title_draft,
placeholder="Title",
key="title-input",
on_change=lambda e: app.set_state(
lambda s: setattr(s, "title_draft", e.value)
),
),
TextArea(
value=app.state.body_draft,
placeholder="Write your note here…",
key="body-input",
on_change=lambda e: app.set_state(
lambda s: setattr(s, "body_draft", e.value)
),
),
Row(
style=Style(gap=8.0),
key="composer-actions",
children=[
Button(
label="Save" if not app.state.saving else "Saving…",
on_click=save_note,
key="save-btn",
),
Button(label="Reload list", on_click=refresh_list, key="reload-btn"),
],
),
]
if app.state.saving:
composer_children.append(Spinner(key="save-spinner"))
Dica — label condicional no botão
label="Save" if not app.state.saving else "Saving…" é o jeito idiomático de dar feedback visual durante uma operação assíncrona sem mudar a estrutura da árvore — o mesmo nó de botão com key="save-btn" é atualizado, não substituído.
Lista de notas¶
list_children: list[Widget] = [
Text(content="Saved notes", key="list-title"),
]
if app.state.loading:
list_children.append(Spinner(key="list-spinner"))
elif not app.state.keys:
list_children.append(
Text(
content="No notes yet — type a title and hit Save.",
key="empty-hint",
)
)
else:
for key in app.state.keys:
list_children.append(
Row(
style=Style(gap=8.0),
key=f"row-{key}",
children=[
Text(content=key, key=f"key-{key}"),
Button(
label="Open",
on_click=open_note_handler(key),
key=f"open-{key}",
),
Button(
label="Delete",
on_click=delete_note_handler(key),
key=f"delete-{key}",
),
],
)
)
Nota — três estados da lista
| Condição | O que é renderizado |
|---|---|
app.state.loading é True |
Spinner |
app.state.keys é [] |
Texto de dica "No notes yet" |
| Há chaves | Uma Row por nota com Open e Delete |
Painel de visualização¶
viewer_children: list[Widget] = []
if app.state.open_key:
viewer_children = [
Text(content=f"Note: {app.state.open_key}", key="viewer-title"),
Text(content=app.state.open_content, key="viewer-body"),
Button(label="Close", on_click=close_note, key="close-btn"),
]
O painel só aparece quando open_key não está vazio — o viewer inteiro é adicionado condicionalmente à lista all_sections.
Montagem final¶
all_sections: list[Widget] = [
Column(
style=Style(gap=8.0, padding=Edge.all(8)),
key="composer",
children=composer_children,
),
Column(
style=Style(gap=6.0, padding=Edge.all(8)),
key="note-list",
children=list_children,
),
]
if viewer_children:
all_sections.append(
Column(
style=Style(gap=6.0, padding=Edge.all(8)),
key="viewer",
children=viewer_children,
)
)
if app.state.error:
all_sections.append(
Text(content=f"Error: {app.state.error}", key="error-strip")
)
return Column(
style=Style(gap=16.0, padding=Edge.all(16)),
key="root",
children=all_sections,
)
O app completo¶
Arquivo completo pronto para copiar:
"""Notes CRUD — persisted via the device storage capability (N3).
A genuinely useful demo of ``tempestweb.native.storage``: the user types a note
title and body, saves it to IndexedDB via ``storage.put``, lists all saved keys,
opens any note, and deletes it. The four storage callables
(:func:`~tempestweb.native.storage.put`, :func:`~tempestweb.native.storage.get`,
:func:`~tempestweb.native.storage.list_keys`,
:func:`~tempestweb.native.storage.remove`) are **injected into** :class:`State`
so the example is deterministic under test and ``build(view(app))`` is green with
no bridge installed.
The demo runs identically in both execution modes::
tempestweb dev --mode wasm # Python in the browser (Pyodide)
tempestweb dev --mode server # Python on the server (FastAPI + WebSocket)
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from tempestweb._core import App, Style, Widget
from tempestweb._core.style import Edge
from tempestweb._core.widgets import (
Button,
Column,
Input,
Row,
Spinner,
Text,
TextArea,
)
from tempestweb.native import storage
# ---------------------------------------------------------------------------
# Callable type aliases for the injected storage capabilities.
# ---------------------------------------------------------------------------
Putter = Callable[[str, str], Awaitable[None]]
Getter = Callable[[str], Awaitable[str]]
Remover = Callable[[str], Awaitable[None]]
KeyLister = Callable[[], Awaitable[list[str]]]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
@dataclass
class State:
"""Application state for the notes storage demo.
Attributes:
title_draft: The title currently typed in the title field.
body_draft: The body currently typed in the body field.
keys: The list of saved note keys fetched from storage.
open_key: The key of the note currently open for reading, or ``""``.
open_content: The content of the open note, or ``""``.
saving: ``True`` while a save operation is in flight.
loading: ``True`` while a list or open operation is in flight.
error: Last error message, or ``""`` when there is no error.
put: Injected callable matching :func:`~tempestweb.native.storage.put`.
get: Injected callable matching :func:`~tempestweb.native.storage.get`.
remove: Injected callable matching
:func:`~tempestweb.native.storage.remove`.
list_keys: Injected callable matching
:func:`~tempestweb.native.storage.list_keys`.
"""
title_draft: str = ""
body_draft: str = ""
keys: list[str] = field(default_factory=list)
open_key: str = ""
open_content: str = ""
saving: bool = False
loading: bool = False
error: str = ""
# Injected capabilities — real implementations by default; only called
# inside async handlers, never during the initial mount/build.
put: Putter = field(default=storage.put)
get: Getter = field(default=storage.get)
remove: Remover = field(default=storage.remove)
list_keys: KeyLister = field(default=storage.list_keys)
def make_state() -> State:
"""Return the initial, blank application state.
Returns:
A fresh :class:`State` ready for the first render.
"""
return State()
# ---------------------------------------------------------------------------
# View
# ---------------------------------------------------------------------------
def view(app: App[State]) -> Widget:
"""Render the notes CRUD UI from the current state.
Layout:
* **Composer** — title + body inputs + Save / Reload buttons.
* **Note list** — scrollable column of (key, Open, Delete) rows.
* **Note viewer** — title + content shown when a note is open.
Args:
app: The application handle exposing ``state`` and ``set_state``.
Returns:
The full widget tree for the current state.
"""
# ------------------------------------------------------------------ handlers
async def save_note() -> None:
"""Persist the current draft to storage under ``title_draft``."""
title = app.state.title_draft.strip()
body = app.state.body_draft
if not title:
return
app.set_state(lambda s: setattr(s, "saving", True))
try:
await app.state.put(title, body)
def _on_saved(s: State) -> None:
s.saving = False
s.title_draft = ""
s.body_draft = ""
s.error = ""
app.set_state(_on_saved)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_save_error(s: State) -> None:
s.saving = False
s.error = msg
app.set_state(_on_save_error)
async def refresh_list() -> None:
"""Fetch all stored note keys and update the list."""
app.set_state(lambda s: setattr(s, "loading", True))
try:
keys = await app.state.list_keys()
def _on_keys(s: State) -> None:
s.loading = False
s.keys = keys
s.error = ""
app.set_state(_on_keys)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_list_error(s: State) -> None:
s.loading = False
s.error = msg
app.set_state(_on_list_error)
def open_note_handler(note_key: str) -> Callable[[], Awaitable[None]]:
"""Return an async click handler that opens *note_key*.
Args:
note_key: The storage key to open.
Returns:
A parameterless async callable suitable for ``Button.on_click``.
"""
async def _open() -> None:
app.set_state(lambda s: setattr(s, "loading", True))
try:
content = await app.state.get(note_key)
def _on_open(s: State) -> None:
s.loading = False
s.open_key = note_key
s.open_content = content
s.error = ""
app.set_state(_on_open)
except Exception as exc: # noqa: BLE001
msg = str(exc)
def _on_open_error(s: State) -> None:
s.loading = False
s.error = msg
app.set_state(_on_open_error)
return _open
def delete_note_handler(note_key: str) -> Callable[[], Awaitable[None]]:
"""Return an async click handler that deletes *note_key*.
Args:
note_key: The storage key to delete.
Returns:
A parameterless async callable suitable for ``Button.on_click``.
"""
async def _delete() -> None:
try:
await app.state.remove(note_key)
# Also remove from local list immediately for a snappy UI.
def _on_delete(s: State) -> None:
s.keys = [k for k in s.keys if k != note_key]
if s.open_key == note_key:
s.open_key = ""
s.open_content = ""
s.error = ""
app.set_state(_on_delete)
except Exception as exc: # noqa: BLE001
msg = str(exc)
app.set_state(lambda s: setattr(s, "error", msg))
return _delete
def close_note() -> None:
"""Clear the open-note panel."""
def _close(s: State) -> None:
s.open_key = ""
s.open_content = ""
app.set_state(_close)
# ---------------------------------------------------------- composer section
composer_children: list[Widget] = [
Text(content="New note", key="composer-title"),
Input(
value=app.state.title_draft,
placeholder="Title",
key="title-input",
on_change=lambda e: app.set_state(
lambda s: setattr(s, "title_draft", e.value)
),
),
TextArea(
value=app.state.body_draft,
placeholder="Write your note here…",
key="body-input",
on_change=lambda e: app.set_state(
lambda s: setattr(s, "body_draft", e.value)
),
),
Row(
style=Style(gap=8.0),
key="composer-actions",
children=[
Button(
label="Save" if not app.state.saving else "Saving…",
on_click=save_note,
key="save-btn",
),
Button(label="Reload list", on_click=refresh_list, key="reload-btn"),
],
),
]
if app.state.saving:
composer_children.append(Spinner(key="save-spinner"))
# ----------------------------------------------------------------- note list
list_children: list[Widget] = [
Text(content="Saved notes", key="list-title"),
]
if app.state.loading:
list_children.append(Spinner(key="list-spinner"))
elif not app.state.keys:
list_children.append(
Text(
content="No notes yet — type a title and hit Save.",
key="empty-hint",
)
)
else:
for key in app.state.keys:
list_children.append(
Row(
style=Style(gap=8.0),
key=f"row-{key}",
children=[
Text(content=key, key=f"key-{key}"),
Button(
label="Open",
on_click=open_note_handler(key),
key=f"open-{key}",
),
Button(
label="Delete",
on_click=delete_note_handler(key),
key=f"delete-{key}",
),
],
)
)
# --------------------------------------------------------------- note viewer
viewer_children: list[Widget] = []
if app.state.open_key:
viewer_children = [
Text(content=f"Note: {app.state.open_key}", key="viewer-title"),
Text(content=app.state.open_content, key="viewer-body"),
Button(label="Close", on_click=close_note, key="close-btn"),
]
# --------------------------------------------------------------- error strip
all_sections: list[Widget] = [
Column(
style=Style(gap=8.0, padding=Edge.all(8)),
key="composer",
children=composer_children,
),
Column(
style=Style(gap=6.0, padding=Edge.all(8)),
key="note-list",
children=list_children,
),
]
if viewer_children:
all_sections.append(
Column(
style=Style(gap=6.0, padding=Edge.all(8)),
key="viewer",
children=viewer_children,
)
)
if app.state.error:
all_sections.append(
Text(content=f"Error: {app.state.error}", key="error-strip")
)
return Column(
style=Style(gap=16.0, padding=Edge.all(16)),
key="root",
children=all_sections,
)
Rodando o exemplo ▶¶
Modo A — Python no browser (Pyodide / WASM)¶
Python roda dentro do browser via Pyodide. A FFIBridge é instalada automaticamente. As chamadas a storage.put/get/list_keys/remove vão direto para client/native/storage.js, que usa o IndexedDB do browser.
Modo B — Python no servidor (FastAPI + WebSocket)¶
Python roda no servidor; a ProxyBridge é instalada automaticamente. Cada chamada nativa serializa um envelope native_call pelo WebSocket, o browser executa o IndexedDB e devolve um envelope native_result.
Verificação
Em qualquer modo, você deve ver:
- Seção "New note" com campo de título, campo de corpo, botões "Save" e "Reload list"
- Seção "Saved notes" com o texto "No notes yet — type a title and hit Save."
- Digite um título, um corpo e clique Save → os campos limpam; "Saving…" aparece brevemente
- Clique Reload list → a nota aparece na lista com botões Open e Delete
- Clique Open → painel de visualização aparece com título e conteúdo
- Clique Close → painel fecha
- Clique Delete → linha desaparece imediatamente; recarregue para confirmar
Verificação automatizada ✅¶
Rode os cinco checks antes de commitar:
# Lint
ruff check .
# Formatação
ruff format --check .
# Tipos
mypy --strict tempestweb
# Testes (7 testes, todos verdes)
pytest -q
Todos passam em verde. O check build(view(app)) sem bridge é o Teste 1 da suite — garante que o render inicial é determinístico mesmo sem browser.
Como testar com capacidades nativas¶
O padrão de injeção de dependência que você viu no estado facilita muito os testes. Veja como o FakeBridge da suite funciona:
from typing import Any
from tempestweb.native import install_bridge, uninstall_bridge
class FakeBridge:
"""In-memory storage bridge for testing native.storage calls.
Backs ``storage.put`` / ``storage.get`` / ``storage.list`` /
``storage.remove`` with a plain Python ``dict``. Any other capability
returns ``ok: False`` so tests fail explicitly if something unexpected is
invoked.
Attributes:
store: The backing dictionary (``{key: content}``).
calls: Every envelope dispatched through the bridge (audit log).
"""
def __init__(self) -> None:
"""Initialise with an empty store and empty call log."""
self.store: dict[str, str] = {}
self.calls: list[dict[str, Any]] = []
async def call(self, envelope: dict[str, Any]) -> dict[str, Any]:
"""Dispatch a native call envelope to the in-memory store."""
self.calls.append(envelope)
cap: str = envelope.get("capability", "")
args: dict[str, Any] = envelope.get("args", {})
if cap == "storage.put":
self.store[args["name"]] = args["content"]
return {"ok": True, "value": {}}
if cap == "storage.get":
name = args["name"]
if name not in self.store:
return {"ok": False, "error": "not_found", "message": f"{name!r} not found"}
return {"ok": True, "value": {"content": self.store[name]}}
if cap == "storage.list":
return {"ok": True, "value": {"keys": list(self.store.keys())}}
if cap == "storage.remove":
name = args["name"]
if name not in self.store:
return {"ok": False, "error": "not_found", "message": f"{name!r} not found"}
del self.store[name]
return {"ok": True, "value": {}}
return {"ok": False, "error": "unavailable", "message": f"unknown cap {cap!r}"}
E a fixture pytest que instala e remove o fake automaticamente:
import pytest
@pytest.fixture(autouse=True)
def _clean_bridge():
"""Install a fresh FakeBridge before each test; remove it after."""
bridge = FakeBridge()
install_bridge(bridge)
yield bridge
uninstall_bridge()
Dica — por que install_bridge em vez de apenas substituir os campos do estado?
Você poderia sobrescrever app.state.put = fake_put antes de cada teste — isso também funciona. A vantagem de instalar um FakeBridge completo é que ele intercepta qualquer capacidade nativa que o código venha a chamar, não apenas as que você antecipou. O log self.calls também permite verificar os envelopes enviados — útil para auditar que a API contratual está correta.
Como funciona por dentro¶
O ciclo de atualização com I/O assíncrono¶
Clique em "Save" (save_note)
│
▼
app.set_state(saving=True) ← re-render imediato: label muda para "Saving…"
│
▼
await app.state.put(title, body)
│ Mode A: FFI direto para IndexedDB (sem rede)
│ Mode B: native_call → WebSocket → browser → native_result → aqui
▼
app.set_state(_on_saved) ← re-render: campos limpos, saving=False
│
▼
view(app) chamada novamente → nova árvore de widgets
│
▼
reconciliador calcula diff (patches)
│
▼
DOM atualizado — apenas os nós que mudaram
Comparação: storage.put vs. storage.get¶
| Função | Retorna | Levanta em erro |
|---|---|---|
storage.put(name, content) |
None |
NativeError("quota_exceeded") |
storage.get(name) |
str |
NativeError("not_found") |
storage.remove(name) |
None |
NativeError("not_found") |
storage.list_keys() |
list[str] (pode ser []) |
nunca levanta NotFoundError |
Por que key=f"open-{key}" e não key=f"open-{index}"?¶
Se você usasse key=f"open-{index}", excluir a nota no índice 0 faria a antiga nota do índice 1 herdar a chave "open-0" — o reconciliador interpretaria isso como uma atualização do nó existente, não como uma remoção + inserção. Com key=f"open-{key}" (baseado no próprio nome da nota), cada linha tem identidade estável e o reconciliador faz a remoção corretamente.
Recapitulando¶
Neste tutorial você aprendeu:
- ✅ Usar
tempestweb.native.storagepara persistir dados em IndexedDB nos dois modos - ✅ Injetar callables de capacidade nativa no estado para que o render inicial seja bridge-free
- ✅ Escrever handlers assíncronos com o padrão "otimismo + rollback" (
saving=Trueantes do await) - ✅ Usar o padrão factory de handler para closures com chave capturada
- ✅ Implementar remoção otimista da lista local sem round-trip adicional
- ✅ Testar capacidades nativas com um
FakeBridgein-memory einstall_bridge/uninstall_bridge - ✅ Garantir que
build(view(app))sem bridge nunca levanta exceção
Próximos passos¶
Experimente estender o exemplo:
- 💡 Adicione um campo
created_atàs notas (serialize como JSON nocontent) e ordene a lista por data - 💡 Implemente edição de notas: clique em Open para carregar o draft nos campos e salve sobre a mesma chave
- 💡 Adicione uma busca por prefixo filtrando
app.state.keysna renderização — sem round-trip extra - 💡 Explore
tempestweb.native.clipboardpara copiar o conteúdo de uma nota com um botão (veja o exemplo Clipboard & Share) - 💡 Combine com PWA Web Push para notificar o usuário quando uma nota é salva em outro dispositivo