eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsWrite-once
Capacidades de dispositivo
Editar esta página
20 min de leitura
O que a máquina consegue fazer (câmera, localização, fotos, biometria, a rede) como serviços tomados por construtor, realizados por host. Um componente pede uma capacidade e nunca aprende qual alvo respondeu.
1
2
3
4
5
6
7
8
public sealed class ScanShell(ICamera? camera, ILocation? location) : StatefulComponent
{
private async void LocateAsync()
{
if (location is null) return; // este host não tem
var here = await location.GetCurrentAsync();
}
}
Anulável, sempre. Um host que não consegue fazer algo não registra nada, então o serviço resolve para null e o app mostra aquilo que ele já sabe mostrar. Essa é a resposta do framework em todo lugar: reportar a ausência em vez de fingir com um stub que falha mais tarde, num momento pior.
Capacidade
O que responde
Desde
IPhotoLibrary
GetPermissionAsync(), PickImageAsync(), uma foto que o usuário escolheu
0.2.0-preview.1
ICamera
StartPreviewAsync() para uma ICameraSession: uma textura ao vivo, e o Capture() dela para uma foto
0.2.0-preview.1
ILocation
GetCurrentAsync(), e Subscribe(…) para o fluxo de mudanças
0.2.0-preview.1
IBiometrics
IsAvailable, AuthenticateAsync(reason): Face ID / Touch ID / o da plataforma
0.2.0-preview.1
IMotionSensor
Subscribe(…) para leituras de movimento do dispositivo
0.2.0-preview.1
INetworkStatus
Current, e Subscribe(…): alcançabilidade e o que a transporta
0.2.0-preview.1
ITextClipboard
Read() / Write(text), para o botão de copiar de uma página e para o que um campo nativo copia, recorta e cola. Todos os alvos, os telefones inclusive
0.2.0-preview.1
IThemeController
O interruptor claro/escuro. Veja ServerIntegration
0.2.0-preview.1
IAppStorage / ISecretStore
Preferências duráveis e segredos. Veja Storage
0.2.0-preview.10
IClock
Every(intervalo, onTick): o tempo, como algo que um componente pode assinar
0.2.0-preview.31
IFrameTicker
OnFrame(tick): um retorno antes de cada frame, com o tempo e o delta — para o que um estilo não consegue exprimir
0.2.0-preview.46
IUiDispatcher
IsOnUiThread, Post(trabalho): a thread em que se pode tocar na UI, e o caminho até ela
0.2.0-preview.46
IConsent
State, Grant(), Deny(): a resposta do visitante ao rastreamento não essencial, o cookie que o instalador do GTM condiciona. Veja Analytics
0.2.0-preview.46
IDeepLinks
Launch, Subscribe(onOpened): os URLs com que esta app é aberta. Photon
0.2.0-preview.47
IFileDialogs
PickFile, PickFiles, PickFolder, PickSavePath: pedir um ficheiro a uma PESSOA. Desktop
0.2.0-preview.47
IWorkspace
Reveal(caminho), OpenFile(caminho), OpenUrl(url), CanOpen(url): entregar um caminho ou um link ao SISTEMA, pelos esquemas que a app abre. Desktop
0.2.0-preview.49
IServerEvents
Subscribe(tópico, onEvent, onRefused), Connection, OnConnectionChanged(…): o que o servidor publica num tópico tipado, por uma conexão por página. Web. Veja Eventos do servidor
0.2.0-preview.61
Desde 0.2.0-preview.31
A thread em que se pode tocar na UI
Uma tela é construída, medida e desenhada numa ÚNICA thread — a principal da plataforma em todo alvo nativo, e a única que existe num navegador. Todo o resto do que uma app faz acontece noutro lado: um scanner a percorrer o disco, um download, uma leitura de base de dados. Quando isso termina, quer mostrar o que encontrou, e o SetState é o caminho.
Isso era uma corrida de dados sem mensagem de erro. O SetState alterava os campos do componente a partir de qualquer thread que o chamasse, enquanto a thread de render lia essa mesma árvore: um frame desenhado a partir de metade do estado velho, uma coleção percorrida enquanto cresce, um crash que aparece uma vez por semana.
Não há nada para ligar. O SetState pergunta ao IUiDispatcher antes de tocar em nada. Fora da thread de UI, a mutação inteira é posta em fila e corre no topo do frame seguinte, antes de qualquer coisa ser construída; nela, corre inline, exatamente como antes. Onde não há outra thread não há nada a transferir, e a resposta honesta é a capacidade dizer que já está na thread de UI — o navegador tem uma só, e um servidor renderiza o pedido na thread que o pediu.
E a linha que ele não atravessa. O dispatcher torna seguro o estado que atravessa o SetState. Estado partilhado que uma thread de trabalho toca POR FORA continua a ser do app — um modelo que a app poda numa thread de fundo, uma coleção a que uma task acrescenta. Uma app que lesse esta capacidade como "threading está resolvido" e apagasse a sua própria guarda estaria a apagar a única coisa entre o passe de render e uma árvore a ser modificada debaixo dele. O que o dispatcher dá a essa guarda é uma garantia que vale a pena: o SetState que LEVANTA o flag corre inline quando vem da thread de UI, portanto o flag já está de pé antes de o trabalho arrancar.
Desde 0.2.0-preview.46
O relógio de frames
O IClock é periódico de propósito — centenas de milissegundos para cima — e a cerca dele diz porque a animação por frame não foi oferecida ali. O IFrameTicker é esse outro contrato: um retorno antes de cada frame, com o tempo do frame e o delta desde o último que aquele assinante viu, na thread de UI e ANTES de a árvore ser construída — então um SetState dentro do tick é o que esse mesmo frame desenha.
1
2
3
4
protected override void OnMount() =>
_ticking = Ticker.OnFrame(tick => SetState(() => _phase += tick.DeltaMs / 1000f));
protected override void OnUnmount() => _ticking?.Dispose();
Uma assinatura MANTÉM OS FRAMES A CORRER, que é o objetivo e também o custo: descarte quando o movimento parar. Num servidor não há frames, então assinar funciona e nunca dispara.
A maior parte do movimento não precisa disto. Uma mudança de estilo desliza sozinha agora (ver Photon); o ticker é para o que um estilo não consegue exprimir — física, uma simulação, geometria recalculada a cada frame a partir de dados que não param.
Desde 0.2.0-preview.46
Um link para dentro da app
O acme://activate?key=… de um e-mail de licença, um regresso de um login no navegador, um colega a colar um link para um ecrã. Declarar o esquema é builder.Bundle.UrlScheme("acme") (veja Photon); o IDeepLinks é a app a LER com que foi aberta. Sem as duas metades, um deep link abre a app à porta da frente e deita fora o que trazia, o que para quem clicou é igualzinho a um link partido.
1
2
3
4
5
6
7
protected override void OnMount()
{
if (Links.Launch is { } opened) Activate(opened); // o que ARRANCOU esta app
_link = Links.Subscribe(Activate); // e todos os seguintes
}
protected override void OnUnmount() => _link?.Dispose();
Faça as duas, e eis porquê. O Launch NÃO é legível num construtor no macOS — medido, não deduzido. O URL de arranque chega como um AppleEvent, os AppleEvents são entregues PELO run loop, e o run loop arranca depois de a primeira árvore ser construída: um arranque a frio chega ao SUBSCRITOR e encontra a propriedade ainda nula um instante antes. A subscrição é o que apanha um arranque a frio; a propriedade é para perguntar depois, num ecrã construído mais tarde. Uma app que faz as duas está certa em todo o lado.
O Launch não é reproduzido para quem subscreve, portanto uma app que faz as duas age uma vez. Um URL que a app não RECONHECE é entregue na mesma — decidir o que um host desconhecido significa é da app, e engoli-lo seria decidir por ela sem deixar rasto. Uma string que não é um URL é descartada.
Só Photon: na web um deep link é a barra de endereço e o router já é dono dela.
Desde 0.2.0-preview.47
Pedir um ficheiro a uma pessoa
A única forma de uma app em sandbox tocar no que não lhe foi dado, e a razão de existir o PhotonEntitlements.UserSelectedFiles.
1
2
3
var pasta = await Dialogs.PickFolder("Escolha uma pasta para varrer");
var relatorio = await Dialogs.PickSavePath("relatorio.csv",
filters: [new FileFilter("Planilhas", "csv")]);
Null quer dizer que a pessoa CANCELOU, que é uma resposta ordinária e não uma exceção: quem fecha um diálogo não falhou em nada, e uma app que tem de apanhar exceções para descobrir é uma app que se vai esquecer. O PickFiles responde com uma lista vazia pela mesma razão — quem vai iterar não devia ter de perguntar duas vezes.
Chame de onde quiser. Os painéis são modais e só da thread principal, e a realização marshala pelo IUiDispatcher sozinha, porque um painel aberto de uma thread de trabalho não abre de todo — não aparece, e não regista nada.
Um caminho vale para ESTA execução. Numa app em sandbox a permissão que a pessoa deu ao escolher um ficheiro não sobrevive a um relançamento; isso precisa de um bookmark com escopo de segurança, que é moeda diferente e ainda não é oferecida. Uma app que guarda o caminho e o lê no arranque seguinte é recusada sem explicação. Só desktop: o seletor de um telemóvel responde com um URL com escopo, não com um caminho.
Desde 0.2.0-preview.47
Entregar algo ao sistema
A outra metade do IFileDialogs: aquele pede um caminho a uma pessoa, este devolve um caminho — ou um link — ao sistema. O que um scanner de disco faz depois de encontrar o culpado, o que um ecrã de definições faz com o link para a sua própria página de privacidade, o que uma app faz com o relatório que acabou de escrever e a pessoa quer na sua planilha.
1
2
3
4
Workspace.Reveal(pasta); // mostrar onde ESTÁ, selecionado, sem abrir
Workspace.OpenFile(caminhoDoRelatorio); // entregar a quem é dono desse tipo de ficheiro
Workspace.OpenUrl(new Uri("https://…")); // um navegador, para este
if (Workspace.CanOpen(linkAcme)) … // uma PERGUNTA: sem diálogo, sem efeito
Cada um responde se o sistema aceitou. false é uma resposta ordinária e não uma exceção: um caminho pode desaparecer entre a varredura e o clique, e um esquema pode não ter nada por trás nesta máquina. A linha é QUEM errou — o sistema a recusar algo que podia ter sido pedido é false; um caminho relativo ou um URL relativo, que nenhum sistema podia receber, é erro de quem chamou e lança. O Reveal mostra um ficheiro ou pasta selecionado no navegador de ficheiros sem o abrir; o OpenFile recebe um FICHEIRO e entrega-o a quem é dono do seu tipo. Só desktop, pela mesma razão dos diálogos: um telemóvel tem uma folha de partilha e um fornecedor de documentos, que não são estes.
Desde 0.2.0-preview.49
Que links abre
Um URL é entregue pelo esquema, e só pelos esquemas que a sua app abre. Entregar um URL ao sistema operativo é entregá-lo a quem quer que reclame o esquema: https chega a um navegador, que é o que "abrir este link" quer dizer; file lança o que o caminho nomeia; outros esquemas abrem sessões ou correm a app que os registou. Um link que chegou em conteúdo — um item de um feed, um valor que um servidor respondeu — nunca pode escolher entre esses, por isso toda realização consulta uma OpenUrlPolicy antes da plataforma. Por omissão entrega http, https e todo esquema que a app declarou responder com builder.Bundle.UrlScheme(…) — o SDK já os conhece, ninguém os diz duas vezes. Tudo o resto é uma linha no Program.cs:
1
2
3
builder.Workspace.OpensMail(); // mailto: — um botão "Contactar o suporte"
builder.Workspace.OpensPhone().OpensMessages(); // tel:, sms:
builder.Workspace.Opens("x-apple.systempreferences"); // o de outra app, ou do sistema, pelo nome
Um URL recusado responde false, exatamente como o sistema responde a um esquema que nada reclama, e a realização regista no log o esquema e a linha que o abriria — não uma exceção, porque a política existe para URLs que a app não escreveu, e um link hostil tem de ser algo que um handler de clique entrega sem um try/catch à volta. O CanOpen aplica a mesma política, por isso um botão condicionado por ele desaparece exatamente para os links que o OpenUrl recusaria. file não se abre por opt-in: um ficheiro tem portas tipadas, OpenFile e Reveal, que verificam que o caminho existe e nunca lançam uma pasta. Uma app com regras próprias regista a sua própria OpenUrlPolicy antes do Build(), e a do builder cede.
Desde 0.2.0-preview.50
Respostas que não ficam paradas
ILocation, IMotionSensor e INetworkStatus são as que MUDAM de resposta, então devolvem um IDisposable do Subscribe em vez de um valor. Descarte-o quando o componente sair, ou a assinatura sobrevive àquilo que ela atualizava.
O INetworkStatus reporta alcançabilidade como a plataforma a enxerga, não uma promessa de que um host específico vai responder. Um app mostrando um "online" velho é pior que um que não diz nada.
O IClock é aquele cuja resposta é SÓ mudança, e é o que faltava para uma tela que avança sozinha: um carrossel, um contador ao vivo, um status que faz polling. Todo o resto aqui reage a algo que uma pessoa fez.
1
2
3
4
5
6
7
8
9
10
public sealed class Carousel(IClock clock) : StatefulComponent
{
private IDisposable? _tick;
private int _slide;
protected override void OnMount() =>
_tick = clock.Every(TimeSpan.FromSeconds(4), () => SetState(() => _slide = (_slide + 1) % 3));
protected override void OnUnmount() => _tick?.Dispose();
}
O par é o contrato: o OnMount assina, o OnUnmount descarta, e sem a segunda metade cada navegação deixa um timer rodando contra um componente que ninguém mais vê. No SERVIDOR o relógio nunca dispara, e essa é a resposta certa em vez de uma que falta: um servidor renderiza um frame, então o primeiro paint é o estado com que o componente foi construído e o tique começa na hidratação. Um telefone que passou dez minutos no bolso volta com UM tique, nunca com seiscentos.
É um relógio PERIÓDICO e não um relógio de frame, de propósito. Animação por frame precisa de duas coisas que ainda não existem, retenção de estado posicional pelo reconciler e um canal de geometria no StyleChannels, e um callback a 60Hz sem elas seria a forma mais lenta de animar.
Permissões
PermissionState tem quatro respostas, e a terceira é a que os apps esquecem:
•
NotDetermined: nunca perguntado. Perguntar mostra o prompt do sistema.
•
Granted.
•
Denied: recusado, e perguntar de novo não faz nada. Só os ajustes do sistema podem mudar isso, então um botão que repergunta é um botão que não faz nada; mande a pessoa para os ajustes.
•
A plataforma também pode reportar uma restrição que o usuário não consegue levantar de jeito nenhum.
No nativo, os textos que o SO mostra no prompt vêm do assembly:
1
[assembly: PhotonCapability("camera", "Escaneie um código de barras para adicionar um item.")]
O build lê isso para o manifesto da plataforma (Info.plist, o manifesto do Android), então a razão que o usuário lê é declarada uma vez, no app, ao lado do código que precisa dela.
CRUZA POR NOME, NUNCA POR ORDINAL O manifesto casa pela string da capacidade. Inserir um valor no meio de um enum uma vez transformou Location em Motion num manifesto publicado: o build ficou verde e o app pediu a permissão errada.
Qual shell responde
Um componente nunca pergunta em que alvo está. Quem escolhe os alvos ainda precisa saber o que cada um entrega, então esta é a resposta por contrato, para os shells nativos:
Contrato
macOS
iOS
Android
Windows
IPhotoLibrary
✓
✓
✓
✓
ICamera
✓
✓
✓
indisponível
ILocation
✓
✓
✓
indisponível
IBiometrics
✓
✓
✓
indisponível
IMotionSensor
✓
✓
✓
indisponível
INetworkStatus
✓
✓
✓
✓
ITextClipboard
✓
✓
✓
✓
IAppStorage
✓
✓
✓
✓
ISecretStore
✓
✓
✓
✓
IDeepLinks
✓
null
null
✓
IFileDialogs
✓
null
null
✓
IWorkspace
✓
null
null
✓
•
✓: o shell tem uma realização. Se ESTA máquina tem o hardware continua sendo a resposta do próprio contrato: IMotionSensor.IsAvailable é false num Mac, que tem a API e não tem o sensor.
•
indisponível: o shell registra um objeto cujo IsAvailable é sempre false, então uma página escrita para um telefone ainda se constrói ali e mostra o seu ramo "não neste dispositivo".
•
null: o shell não registra nada, e o serviço resolve para null. IFileDialogs e IWorkspace são contratos de desktop, e esta é a resposta deles num telefone.
O navegador é a próxima seção.
A câmera num telefone
ICamera responde no Android como responde no iOS (Desde 0.2.0-preview.62). Até então UseCamera(reason) escrevia a permissão no manifesto e o shell não registrava nada, então um CameraPreview desenhava o seu estado de indisponível no único telefone que não a tinha.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public sealed class ScanPage(ICamera? camera) : StatefulComponent
{
private ICameraSession? _session;
private async void StartAsync()
{
if (camera is null) return;
var session = await camera.StartPreviewAsync(); // pergunta enquanto o sistema ainda pergunta, null quando recusado
SetState(() => _session = session);
}
public override VisualNode Build(ComponentContext context) =>
CameraPreview(_session, 320, 320, cornerRadius: new CornerRadii(24), label: "Scanner");
}
•
A permissão está dentro de StartPreviewAsync. Ele mostra o aviso do sistema sempre que o sistema ainda mostra um, e responde null quando a pessoa recusa. Permission lê Denied só quando perguntar de novo não mostraria nada, que é o que a palavra quer dizer em todo alvo: no iOS isso é a primeira recusa, e no Android 11 em diante é a segunda, então depois de uma recusa ali ele ainda lê NotDetermined e uma segunda pergunta mostra o aviso. O Android só diz "concedida" ou "não": o shell lembra a última resposta, e é isso que separa uma permissão nunca pedida de uma recusada de vez.
•
Os quadros chegam como pixels, em pé para o jeito como o dispositivo está sendo segurado. O preview é um nó comum: ele recorta, aceita raio de canto e fica por baixo do que for desenhado sobre ele.
•
Capture() devolve o quadro que está na tela como uma imagem codificada (image/jpeg no Android).
•
A câmera fecha quando a app sai da tela e abre de novo quando ela volta, na mesma sessão. Uma app que está só pausada e ainda à vista, ao lado de outra janela ou debaixo de um aviso do sistema, mantém o preview.
O web
O browser realiza o que honestamente consegue (fotos por um file input; câmera, localização e rede pelas APIs web deles) e registra o resto como indisponível em vez de ausente, para que uma página que tome uma delas ainda receba um objeto e mostre o fallback, em vez de falhar ao construir.
Uma página toma o que precisa pelo construtor em todos os alvos:
1
2
3
4
5
public sealed class ProfilePage(IPhotoLibrary photos) : StatefulComponent
{
public override VisualNode Build(ComponentContext context) =>
photos.IsAvailable ? Button(label: "Escolher uma foto", onPressed: Pick) : Text("Sem biblioteca aqui");
}
No nativo o ActivatorUtilities resolve. No browser o construtor transpilado resolve sozinho, pelo NOME da interface: um tipo C# não existe em tempo de execução lá, mas IPhotoLibrary como string existe, e os dois lados concordam nisso.
Seja qual for a forma do construtor. Um primary constructor seguia outro caminho no parser até a 0.2.0-preview.33, e uma seção escrita PairLoop(IClock clock) recebia if (clock !== undefined) this.clock = clock: ninguém que a compõe no meio de uma árvore passa um relógio, então o campo ficava indefinido e o componente ficava inerte, em silêncio, num alvo só.
E ninguém passa mesmo. A dependência sai do construtor emitido, então sai também do LOCAL DA CHAMADA e da factory gerada: Quark(mood, size) é a assinatura inteira que quem chama vê, e new Quark(clock, mood, size) em C# descarta o relógio na saída em vez de empurrar cada argumento seguinte uma casa adiante. A regra é perguntada ao modelo, não adivinhada de um nome: um parâmetro de construtor cujo tipo é uma INTERFACE é uma dependência, todo o resto é dado que quem chama passa. Um componente toma aquilo que desenha (um rótulo, uma variante, um callback) e nenhum desses é jamais uma interface. (IReadOnlyList<T> e IEnumerable<T> são dado, explicitamente.)
Um componente no meio de uma árvore
Desde 0.2.0-preview.18
1
2
3
4
5
6
7
public sealed class CopyButton : StatelessComponent
{
public override VisualNode Build(ComponentContext context) =>
context.GetService<ITextClipboard>() is { } clipboard
? IconButton(Icons.Copy, onPressed: () => clipboard.Write(_code))
: new Box(); // sem área de transferência aqui: desenhe nada, não um botão morto
}
A injeção por construtor continua sendo a melhor resposta onde ela cabe: explícita, testável, legível na assinatura. Mas ela só alcança a PÁGINA. Tudo abaixo precisava receber a mesma coisa na mão: um cartão com um botão de copiar precisa de um ITextClipboard, então o artigo acima dele carregava um que nunca usava, e a seção acima daquela também carregava. Um componente que ganhava uma necessidade forçava uma edição em cada ancestral entre ele e a página.
O context.GetService<T>() responde nulo quando o alvo não tem a capacidade, que é a resposta que quem chama qualquer capacidade tem que tratar de qualquer forma.
De ONDE ele resolve é assunto do host: o SSR usa o container da REQUISIÇÃO (então um registro scoped funciona e os registros da própria página vencem), o browser usa o que o boot registrou, um app Photon usa o do shell. O componente faz a mesma pergunta em todo lugar.
No WEB isso só chegou funcionando na 0.2.0-preview.21. O argumento de tipo era descartado na transpilação: a estratégia que transforma a chamada numa chave reconhecia o RenderContext antigo do Core e um IServiceProvider, e ComponentContext não é nenhum dos dois, então a chamada caía no caminho de invocação comum, que descarta argumentos de tipo. Toda página pedia uma capacidade sem nome nenhum e recebia nulo de volta, justamente no alvo para o qual a feature existe.
Onde não há contexto
Desde 0.2.0-preview.33
1
2
3
4
5
6
7
8
9
public sealed class PairLoop : StatefulComponent
{
private IDisposable? _tick;
protected override void OnMount() =>
_tick = GetService<IClock>()?.Every(Beat, () => SetState(() => _step++));
protected override void OnUnmount() => _tick?.Dispose();
}
OnMount é onde uma inscrição pertence. Ele roda uma vez, na instância que o reconciler mantém, e faz par com OnUnmount, que é o que impede uma página abandonada de seguir tiquetaqueando. Ele não recebe ComponentContext, então antes disso o único jeito de alcançar uma capability em profundidade era dentro do Build atrás de uma guarda de uma vez só: a inscrição morava no único método que o framework chama repetidamente, e o par com OnUnmount parecia acidente.
O acessor no componente responde exatamente o que context.GetService<T>() responde, de qualquer lugar do componente: um hook de ciclo de vida, um handler de evento, o próprio Build.
O que uma capability ausente faz
Desde 0.2.0-preview.33
O C# já diz, então o framework lê em vez de perguntar de novo:
Escrito
Num alvo que tem
Num alvo que não tem
IClock clock
a capability
um erro nomeando a capability e o componente
IClock? clock
a capability
null, para o componente tratar
Um parâmetro não anulável é o componente prometendo que não funciona sem, então um null entregue a ele falha depois, dentro de código que nunca menciona capabilities, no único alvo em que a tela está quebrada. A mensagem nomeia qual capability, qual componente, e as duas saídas: registrar no host, ou declarar o parâmetro anulável. Os dois alvos dizem isso, senão o browser seria o permissivo e o bug só existiria lá.
Um arquivo com nullable desligado não dá sinal nenhum, e mantém o significado que sempre teve.
Entregando uma num teste
Desde 0.2.0-preview.33
1
2
3
4
using var _ = CapabilityScope.With<IClock>(fake);
var section = new PairLoop();
section.NotifyMounted();
Uma capability, sobre o que já estiver em vigor, devolvida no dispose. Aninhar compõe. A cerimônia que ela substitui era um resolver escrito à mão, que respondia null a toda OUTRA capability que o componente pudesse pedir e precisava ser desfeito num finally que um teste podia esquecer.
Renderizando no servidor
Desde 0.2.0-preview.13
A mesma página tem que ser renderizável no servidor, e o servidor não tem câmera. Toda capacidade resolve lá para uma realização AUSENTE: ela se reporta indisponível e não devolve nada.
Sem isso a página não poderia nem ser construída: nenhum construtor que o container conseguisse satisfazer, e a requisição terminava em 500. A única página que faz algo era a única página que um crawler nunca via, e o visitante esperava o JavaScript só para ouvir que a página existe.
Ausente, deliberadamente, e não simulado. Não há câmera num datacenter e o localStorage do visitante está na máquina do visitante, então um dublê no servidor seria pior que a falha: a página renderizaria uma coisa, o browser hidrataria outra, e a divergência seria creditada ao reconciliador. A página toma o ramo de disponibilidade que ela já precisa ter, e a capacidade real do boot do cliente substitui o fallback no primeiro render do cliente.
O INetworkStatus é a exceção, e a exceção é o ponto: reportar offline gravaria o banner de offline no markup que todo crawler e todo primeiro paint veem, para visitantes que acabaram de provar que estão online ao buscar a página. Ele responde online.
Registrado com TryAdd, então um app com uma resposta genuína no servidor (um armazenamento respaldado pela sessão do usuário) registra o dele e vence.