Skip to content

WriteOnceComponents pt BR

Edgar Mesquita edited this page Aug 14, 2026 · 4 revisions

Componentes Write-Once (arquitetura compartilhada)

🌐 Esta página em: English · Português

A promessa central da história multi-alvo do eQuantic.UI: componentes são escritos uma vez, em C#, contra um vocabulário abstrato, e realizados por alvo: DOM + CSS no web, pixels de GPU através do Photon no nativo. Não são "duas APIs parecidas", é literalmente a mesma classe.

Esta página é a visão de arquitetura; o catálogo de componentes vive na página Components, e como se ESCREVE uma árvore (factories em vez de new, inclusive para os seus próprios componentes) em Declarative Surface.

As camadas

Pacote Papel Depende de
eQuantic.UI.Primitives A fundação compartilhada: cores, tokens de design, estilos tipados, nós abstratos, modelo de componente nada (zero)
eQuantic.UI.Components A biblioteca de componentes write-once só Primitives
eQuantic.UI.Web Realizer web: árvore abstrata → HtmlElement/DOM + CSS (SSR) + stylesheet gerado Core, Primitives
eQuantic.UI.Native.Components Realizer nativo: árvore abstrata → display list do Photon Primitives, Engine
Runtime TS (@equantic/runtime) Lowering client-side (hidratação) + módulos da biblioteca embarcados nada

O vocabulário abstrato

Estrutura: Box (+BoxStyle: pintura, opacidade, transform, aspect-ratio, diffs de hover/focus), Row/Column (flex com gap + Wrap/RunGap), Grid (+GridTrack, o gêmeo do CSS Grid), Stack/Positioned (ordem z + ZIndex), AdaptiveNode (classes de tamanho de janela, zero listeners), ScrollView (+Sticky), Overlay (camada de viewport, flag Modal), Anchored (painéis flutuantes: posicionamentos, scrim de toque-fora, MatchAnchorWidth, OpenOnHover). Conteúdo: Text (papéis de tipo), TextEntry, Icon (curado + glifo de qualquer pack), Image, Spinner, Markdown (um documento renderizado como o design system), Mermaid (diagramas desenhados por ele). Interação e movimento: Pressable (contrato de alvo ≥48dp), Link (âncoras reais no web, a costura de navegação do host no nativo), DragDismiss (o gesto da folha), LoopMotion, Presence (movimento de entrada/saída). Mais os tipos de valor (EdgeInsets, SizeValue Hug/Fill/Fixed, CornerRadii, ColorToken em pares claro/escuro, StyleDiff, Transform2D) e o modelo de componente (StatelessComponent, StatefulComponent + SetState, ComponentContext sem modo).

O estilo não tem um plano CSS. Veja o engine de estilo atômico em DesignSystem: cada declaração de estilo vira uma classe atômica deduplicada, byte-idêntica entre SSR (C#) e hidratação (TS).

A paridade de layout é um contrato: o engine flex em C# (nativo) e o flex do CSS (web) implementam a mesma spec: Flexible distribuindo a sobra por peso, o contrato de truncamento (o texto encolhe até a elipse antes de empurrar os irmãos), stretch preenchendo só o que é automático.

Desde 0.2.0-preview.6

Painéis responsivos: Flexible com uma base

Flexible(child, flex, basis, shrink) é o trio flex: grow shrink basis do CSS. A base é o tamanho contra o qual um pai com wrap quebra linhas, e é o que torna um layout responsivo expressável:

// `Wrap` é uma propriedade init, então esta é a forma construtor + inicializador:
// as factories cobrem construtores, nunca uma segunda API.
var panes = new Row(gap: Space.S4) { Wrap = true, Width = SizeValue.Fill };
panes.Add(Flexible(EditorPane(), flex: 1, basis: 440));
panes.Add(Flexible(PreviewPane(), flex: 1, basis: 380));

Largo o bastante para as duas bases, os painéis dividem a linha e repartem a sobra por peso. Estreito demais, cada um toma uma linha própria e cresce para preenchê-la, em vez de ficar em 440 com o resto da linha vazio. Essa segunda parte é uma passada de verdade, não efeito colateral da quebra de linha: a sobra vai para quem cresce, por peso; uma linha que transborda é retomada de quem encolhe, ponderada pela base (como o CSS faz); e nada cruza o piso de min-content.

Uma base 0, o padrão, é o comportamento histórico: o filho não contribui com nada próprio e é dimensionado puramente pelo peso. shrink: 0 recusa devolver espaço, então uma linha que não cabe quebra em vez de espremer.

Como a mesma classe alcança os dois alvos

Nativo: o PhotonHost expande Build() inline durante o layout e o realizer emite comandos de desenho.

Web, servidor (SSR): o WebRealizer desce a árvore para HtmlElements. Cores viram light-dark(#l, #d) para o DOM ficar livre de modo (troca de tema = color-scheme). Páginas do Core podem embutir subárvores write-once pela ponte VisualNodeComponent, e um StatefulComponent de Primitives com [Page] é uma página inteira (o servidor faz a ponte automaticamente).

Web, cliente: o eqc (o compilador C#→JS) transpila as mesmas fontes; o lowering do runtime (lowerVisualNode) espelha o WebRealizer regra por regra, e a paridade de hidratação é garantida por strings de estilo cross-pinadas byte a byte afirmadas nas suítes C# e TS.

Uma falha, uma subárvore

Todo alvo expande um componente pela MESMA costura, e essa costura é uma fronteira: se o Build lançar, a subárvore do componente que falhou é substituída por uma superfície contida e tudo em volta continua renderizando. Uma página que não tem pai para contê-la carrega a mesma fronteira na própria costura de render, então uma página quebrada vira um painel, nunca um documento em branco.

antes agora
Servidor (SSR) 500 para a requisição inteira 200, painel no lugar da subárvore
Browser o mount lançou, a raiz nunca escreveu painel, irmãos interativos
Janela (Photon) o frame nunca chegou painel, o app segue apresentando

O painel é construído com o vocabulário, então é a mesma superfície numa página e numa janela. Em desenvolvimento ele nomeia o componente e cita o que foi lançado; em produção diz apenas que uma seção não pôde ser exibida, porque uma mensagem de exceção é escrita para quem escreveu o código e pode citar um id, um caminho ou uma query. De qualquer forma a falha chega ao log do host por ComponentBoundary.Report: uma fronteira que só engole troca um crash barulhento por um silencioso.

Nada é lembrado. Um componente que para de lançar (uma nova tentativa, novas props, um hot reload) simplesmente constrói de novo na próxima passada.

O CONTRATO do próprio componente não é uma falha de render e não pertence aqui: declare-o onde o erro é escrito (init => field = value.Count > 3 ? throw … : value), para que o autor receba uma exceção na linha que errou em vez de um painel vermelho uma vez por frame.

A biblioteca é fornecida pelo runtime

Os módulos transpilados de eQuantic.UI.Components viajam dentro do runtime.js, pinados byte a byte na CI contra a saída viva do eqc, e os apps os importam de @equantic/runtime. Componentes write-once escritos pelo usuário não precisam de fiação: vivem no app e fluem pela varredura normal de páginas (o eqc detecta as formas de componente de Primitives, incluindo stateful com SetState direto).

Gerado, nunca escrito à mão

Todo valor do design system no cliente é gerado da fonte única em C# e pinado byte a byte:

  • PhotonCssGenerator → o stylesheet normativo (custom properties, classes de papel de tipo, elevação, movimento).
  • DesignSystemTsGeneratordesign-system.generated.ts (tokens, tema, tabela de tamanhos do Button).
  • IconTsGeneratoricons.generated.ts (dados de path dos glifos, do IconRegistry em C#).

Veja DesignSystem.

A biblioteca: uma biblioteca, escrita uma vez

Existe exatamente UMA biblioteca de componentes. Botões, cartões, inputs, navegação, overlays, listas, o ListView com reciclagem, a grade Spreadsheet, o editor de código. O catálogo completo, agrupado e descrito, está na página Components.

Cada componente vem com pins do realizer web, goldens nativos (claro+escuro), fixtures transpiladas pinadas executadas no vitest, e o showroom vivo (/ e /shared no DefaultUIDashboard: SSR + identidade de hidratação + interação verificados de ponta a ponta pela suíte Playwright). Sistemas que acompanham a biblioteca: o sistema de movimento por transição de estado (entrada/saída de Presence), o pipeline de ponteiro (hover, arrastar-para-dispensar com slop/cancel/glide), o compositor de scroll (fixação Sticky real) e os overlays ancorados.

Um link dentro de uma frase

Desde 0.2.0-preview.13

TextRun.Destination, porque um link no meio de um parágrafo não tem outro lugar para morar:

new Text("", TypeRole.BodyM)
{
    Spans =
    [
        new TextRun("Leia o "),
        new TextRun("guia inicial") { Destination = "/docs/start", Weight = FontWeight.SemiBold },
        new TextRun("."),
    ],
}

Um Link em volta do Text inteiro faz o parágrafo todo virar um link. Uma Row de Texts quebra entre runs em vez de entre palavras, então uma frase com três spans de código quebra nos spans — que é justamente por que ênfase mista precisa ser um Text com Spans. Entre esses dois, um link no meio da frase era inexpressável, e para prosa isso é a maioria dos parágrafos.

Um run também pode ter TAMANHO diferente da prosa em volta: StyleOverride no run, a mesma válvula de escape que Text.StyleOverride é. Código inline em 13,5 dentro de um parágrafo 16 é o caso, e o run mantém a caixa de linha do parágrafo: definir a própria altura de linha abriria um vão acima e abaixo — isso é uma linha, não um run. Desde 0.2.0-preview.15.

A FORMA é universal: um NSAttributedString da Apple carrega um atributo .link num intervalo, um Spannable do Android carrega um URLSpan. Um link inline é um run com um atributo em todo lugar, e não pode ser um nó separado, porque um nó separado é o que quebra a linha.

O NOME segue a mesma regra. A Apple diz link, o Android diz URLSpan, o HTML diz href — então href é a palavra de um alvo, e a camada abstrata não fala a língua de nenhum alvo (Pressable, não "Button"). É Destination tanto em Link quanto em TextRun: o que o valor É, que é uma rota ou uma URL, numa palavra que nenhum dos três alvos possui.

Renomeado na 0.2.0-preview.13, de Href, no mesmo release em que apareceu. Uma quebra tomada enquanto custa uma linha por app, em vez de depois de existirem apps para quebrar.

Spans é realizado no web hoje. O Photon realiza Link (coleta as regiões do link e faz hit-test nelas, entregando o destino à costura de navegação do shell) mas não o destino por RUN, então um parágrafo com links inline desenha lá como o texto dele, sem link. O que falta é hit-testing por run, não significado.

A rota em que uma página está

Desde 0.2.0-preview.13

Uma página em /docs/{slug} lê o slug do próprio contexto:

[Page("/docs/{slug}")]
public sealed class DocPage : StatelessComponent, IServerPrefetch
{
    private Doc? _doc;

    [ServerOnly]
    public async Task PrefetchAsync(IServiceProvider services, CancellationToken cancellationToken)
        => _doc = await services.GetRequiredService<IDocs>()
            .FindAsync(RouteValues.Current.Param("slug"), cancellationToken);

    public override VisualNode Build(ComponentContext context) =>
        Text(_doc?.Title ?? "Not found", TypeRole.Heading);
}
  • context.Route.Param("slug") no Build, RouteValues.Current.Param("slug") num prefetch (que roda antes de existir um contexto).
  • context.Route.Query("page") para a query string.
  • Nunca nulo; um nome sem correspondência responde null.

Antes disso o único caminho era o IHttpContextAccessor. Funciona, e custa à página justamente o que a torna write-once: um componente que conhece o ASP.NET é um componente que não roda no Photon. Ler um parâmetro de rota é a coisa mais ordinária que uma página faz, e se o jeito de fazer isso é específico do web, então "write-once" vale para o visual e não para a página.

A rota é armada antes do prefetch, e essa é a parte que importa: uma página em /docs/{slug} carrega pelo slug, então uma rota que chega depois do prefetch chega depois da única pergunta que ela existia para responder.

Por requisição, num AsyncLocal como todo valor por requisição, porque o SSR renderiza requisições concorrentes sobre instâncias compartilhadas, e um slug guardado num campo entregaria o documento de um visitante a outro.

Precisa da 0.2.0-preview.14 para ser lido do Build. Na preview.13 o runtime não exportava RouteValues, então uma página que o nomeasse fora de [ServerOnly] morria na hidratação com "does not provide an export named 'RouteValues'", enquanto o SSR seguia respondendo 200 com markup correto — que é por que parecia que nada estava errado. Ler de um prefetch [ServerOnly] funciona nas duas.

Entrando na árvore, e saindo dela

Desde 0.2.0-preview.13

public sealed class LiveRates : StatefulComponent
{
    private IDisposable? _subscription;
    private NetworkState _network;

    protected override void OnMount() =>
        _subscription = _status.Subscribe(state => SetState(() => _network = state));

    protected override void OnUnmount() => _subscription?.Dispose();

    public override VisualNode Build(ComponentContext context) =>;
}

Um construtor não pode ser o lugar de carregar estado guardado, assinar um dispositivo ou iniciar uma requisição: o reconciliador constrói instâncias novas a cada passada e mantém a retida, então qualquer coisa que um construtor inicie é iniciada de novo para uma instância que é jogada fora. O OnMount roda uma vez, na instância que fica.

O OnUnmount veio junto, e não depois, porque sem o segundo todo mount é um vazamento.

Três coisas que vale saber:

  • Entregue quando a passada fecha, não no meio do build. Um hook rodando dentro do reconcile roda enquanto o próprio pai ainda está construindo, então um SetState dali mutaria uma árvore no meio da produção.
  • O que sai desmonta antes de o que entra montar: a ordem que permite dois componentes compartilharem um recurso exclusivo durante uma troca.
  • Não significa "os pixels existem". O Photon não tem DOM de onde ler geometria, então um hook que prometesse isso não poderia ser write-once. Significa: este componente está na árvore, o build dele está prestes a ser mostrado, e um SetState daqui agenda a próxima passada em vez de brigar com a atual.

A RAIZ de uma página não tem pai para montá-la, então a superfície monta: o PhotonHost depois do primeiro frame, a base web no mount/hydrate.

Testar uma árvore sem servidor

Um componente é um valor, então afirmar sobre o que ele renderiza custa um assert, não um processo. O realizer web é público e os próprios testes do framework usam exatamente isto:

using eQuantic.UI.Core.Rendering;
using eQuantic.UI.Web;

var html = HtmlRenderer.RenderNode(
    WebRealizer.Lower(new PriceTag(1999), PhotonTheme.Instance).Render());

html.Should().Contain("R$ 19,99");

Três passos, porque cada um faz uma coisa: Lower(node, theme) realiza a árvore, .Render() transforma o elemento realizado num HtmlNode, e HtmlRenderer.RenderNode(...) escreve esse nó como markup. Parar no .Render() te dá o nó, o que é útil quando a afirmação é sobre estrutura (.Tag, .Attributes) e não sobre texto — que é o que a maioria dos testes do próprio framework quer. Sem um sink de estilo, os estilos ficam inline, que é o que se quer num teste: a forma de classe atômica é para a página, e afirmar sobre um hash de conteúdo é afirmar sobre um artefato de build.

Duas coisas que isso compra além de velocidade. Um teste pode renderizar o MESMO nó nos dois realizers e comparar o que cada um fez com ele — que é como o contrato de paridade de layout é mantido. E um componente que lança é pego pela fronteira, então uma subárvore quebrada falha o próprio assert em vez de levar a suíte junto.

Clone this wiki locally