@loom-forge/forge-react
v0.4.0
Published
forge · a camada React do construtor: createForge/ForgeProvider/ForgeView e o boundary de estado por instância ($stateful).
Downloads
697
Maintainers
Readme
@loom-forge/forge-react
forge · a camada React do construtor. Envolve um engine do
@loom-forge/react com um registry de definições, expande os componentes
custom (pré-passo headless) e renderiza o resultado pelo compositor — que continua sem saber o
que é um ComponentDef.
pnpm add @loom-forge/forge-react reactO @tslite/graph, que vem do forge, é peer (ADR-021): a aplicação fornece uma instância do TSLite, a mesma para todo repo que a compõe. npm 7+ e pnpm 8+ o instalam sozinhos.
import { createForge, ForgeProvider, ForgeView } from "@loom-forge/forge-react";
import { createTsliteBinder } from "@loom-forge/tslite";
const forge = createForge({ binder: createTsliteBinder() })
.register("Card", CardComp)
.defineComponent(userCard); // a definição JSON
<ForgeProvider forge={forge}>
<ForgeView
node={{ id: "u1", type: "UserCard", props: { id: "42" } }}
data={data}
/>
</ForgeProvider>;| export | o quê |
| ------------------------------ | ---------------------------------------------------------------------------------------- |
| createForge(opts?) | engine do compositor + registry de definições. register/defineComponent encadeáveis |
| ForgeProvider · useForge() | o contexto |
| ForgeView | node + data (+ contexts/contextStore e commands do host) → expande e renderiza |
O contexto de ambiente: valores ou STORE
import { createContextStore } from "@loom-forge/core";
const store = createContextStore({ auth, route });
<ForgeView node={page} data={data} contextStore={store} />;
store.set("route", next); // acorda a seção que declarou `route` — e só elaO contexto é dependência declarada (ADR-023 do repo): cada seção pede os nomes que o uses
dela declara, mais os que escapam de quem ela compõe. Com contexts={…} o runtime embrulha os
valores num store estático e a granularidade vem da projeção estável — quem declarou route não
recalcula porque auth mudou. Com contextStore, ela vem da assinatura: mudar um contexto não
visita quem não o lê, e o host publica sem re-renderizar a view.
createForge aceita tudo que o createEngine aceita (binder, resolver, planners,
layoutRenderers, actions, capabilities…), mais requireEntryRoot: um componente custom
PRIVADO entrado como RAIZ degrada para unknown em vez de expandir.
O boundary de estado
Componente com updates ou effects é vivo: a expansão não o expande, emite o marcador
$stateful (dado puro). Quem o materializa é este package — createForge registra o
StatefulBoundary, que segura useState(initState(def)) por instância, re-resolve o próprio
corpo a cada mudança e roteia os eventos: update local primeiro, senão o ActionPort global.
O corpo é expandido com os mesmos insumos da expansão estática: o mesmo bodyOf, os updates do
componente — para que um alias de comando vindo de fora não reescreva um update local — e os
children do call-site. O efeito resolve nos mesmos degraus do evento, pelo vocabulário de onde o
componente montou.
E ele é fronteira de RE-RENDER, não só de estado. O ForgeView guarda a árvore resolvida
anterior e partilha identidade com a nova (shareIdentity do core): a seção que não leu o campo
que mudou volta como o MESMO marcador, com o mesmo callProps, e nem o render nem a re-resolução
do corpo acontecem. Trocar um campo do data faz trabalhar quem lê aquele campo — não o documento
inteiro (ADR-022 do repo).
Um componente vivo RECEBE filhos
O marcador $stateful carrega os children do call-site, crus, e o boundary os entrega à expansão
do corpo: cada { "type": "$slot", "props": { "name": "header" } } recebe os filhos que
declararam aquela área (slot), e quem não declarou nenhuma cai na default. É o mesmo caminho da
expansão estática, um relógio depois — e o contexto que esses filhos enxergam é o de dentro,
que é onde eles de fato montam.
É o que faz um layout de verdade existir: uma casca com header/main/footer e uma sidebar que
colapsa é um componente vivo com slot, e antes disto os filhos sumiam em silêncio.
O boundary não intercepta a porta. A resolução marca local a intenção que é update DESTE
componente, e o engine do corpo ganha updates, que a aplica; o resto sai pela porta do host.
É por ser marca, e não nome, que o evento de um componente puro inlinado no corpo não alcança os
updates de quem o compôs (ADR-016 do repo).
A cadeia de uma intenção, e o pendente (ADR-018 do repo)
Tudo o que uma intenção recebe entra pelos params, e o autor escreve de onde: a raiz event é
o valor recebido — o que o widget emitiu, o data da ação anterior num then, o error num
catch. Os params são avaliados no despacho, sobre o escopo do render mais event (o
bind os deixa compilados e guarda o escopo no binding); quem avalia é o mesmo binder do render.
"onClick": {
"action": "iniciar", // local: set validando = true
"then": {
"action": "corretor/validar-email", // host, async
"params": { "email": "{{ state.dados.email }}" },
"then": { "action": "marcar", "params": { "valido": "{{ event.valido }}" } },
"catch": { "action": "falhou", "params": { "motivo": "{{ event.code }}" } }
}
}O boundary executa a cadeia (runIntent do core): um update local resolve quando o estado foi
escrito (event = undefined); um despacho ao host resolve com data no then ou falha com
error no catch — falha de domínio ou o UNHANDLED_ERROR de um port que lançou. Um fluxo
cancelado (aborted com sucesso) não é resultado e fica só com o host. O host recebe todo
resultado pelo onActionResult, com ou sem continuação. Sem catch, a falha vai só ao host,
como sempre foi.
E o pendente é fato da instância, não estado que o autor escreve: o boundary guarda
pending[ação] — true do despacho ao resultado de uma ação do host — e o expõe como raiz do
escopo do corpo, para um when/disabled ler:
{
"type": "Button",
"props": {
"label": "{{ pending['corretor/validar-email'] ? 'Validando…' : 'Validar' }}",
"disabled": "{{ pending['corretor/validar-email'] }}",
},
}Um componente puro não tem boundary e não tem pending: é raiz só de quem é vivo. O efeito
segue a mesma cadeia — then/catch valem nele, e o event de um efeito é undefined:
"effects": {
"carregar": {
"on": ["props.id"],
"action": "user/load",
"params": { "id": "{{ props.id }}" },
"then": { "action": "guardar", "params": { "nome": "{{ event.nome }}" } },
"catch": { "action": "falhou", "params": { "erro": "{{ event.code }}" } }
}
}É "carregar no mount e guardar o que carregou" sem o host reinjetar nada — e pending["user/load"]
cobre o mount, então um "{{ pending['user/load'] ? 'carregando' : state.nome }}" mostra o
estado certo do primeiro render ao resultado. O cleanup tem a cadeia dele.
É por isso que estado mora na camada React e não no headless: o pré-passo captura a estrutura estática; quem faz viver é a view. Componentes aninhados — inclusive outros vivos — viram seus próprios boundaries, por recursão natural. Ver forge/docs/STATE.md.
