Plataforma técnica · Sevastopol
Hooks Customizados
Sevastopol Hooks SolidJS
Propósito y Alcance
Section titled “Propósito y Alcance”Los hooks de Sevastopol viven en sevastopol/src/lib/hooks/ y encapsulan dos contextos transversales que toda island necesita conocer:
- Tenant activo — qué organización está mirando el usuario.
- Período activo — qué año/mes contable está consultando.
Estos valores cambian desde un único punto (TenantSelectorBar, en la barra superior) y deben propagarse a todas las islands montadas, sin pasar props ni mantener un store global. La solución es un event bus sobre window con CustomEvent: el selector emite, los hooks escuchan, las islands reaccionan.
| Hook | Archivo | Responsabilidad |
|---|---|---|
useActiveTenant | lib/hooks/useActiveTenant.ts | Lee tenant inicial de localStorage y se suscribe a cambios |
useActivePeriod | lib/hooks/useActivePeriod.ts | Lee período {year, month} y se suscribe a cambios + storage |
useTenants | lib/hooks/useTenants.ts | Loader canónico de la lista de tenants (fetch + persistencia) |
Arquitectura: event bus de tenant y período
Section titled “Arquitectura: event bus de tenant y período”flowchart LR
subgraph EMIT["Emisor (único)"]
TSB["TenantSelectorBar<br/>islands/core/"]
end
subgraph BUS["Event bus"]
LS["localStorage<br/>activeTenant · activePeriodYear · activePeriodMonth"]
EVT["window CustomEvent<br/>tenant:changed · period:changed"]
end
subgraph LISTEN["Consumidores (N)"]
UAT["useActiveTenant()"]
UAP["useActivePeriod()"]
ISL["islands/**<br/>EmployeesViewIsland · ActivosFijosIsland · ..."]
end
TSB -->|persiste| LS
TSB -->|dispatchEvent| EVT
EVT --> UAT
EVT --> UAP
LS -.->|onMount lee| UAT
LS -.->|onMount lee| UAP
LS -.->|"storage event<br/>(otras tabs)"| UAP
UAT --> ISL
UAP --> ISL Este patrón es una instancia de Observer: el emisor no conoce a los consumidores y los consumidores no se conocen entre sí. Agregar una nueva island es una suscripción más, sin tocar el emisor.
useActiveTenant
Section titled “useActiveTenant”Suscriptor liviano al evento tenant:changed. Lee el tenant persistido en localStorage como valor inicial y actualiza su señal cada vez que el selector emite un cambio. El callback opcional onChange es donde la island engancha su recarga de datos.
function useActiveTenant(onChange?: (id: string) => void): { tenantId: Accessor<string>;};Implementación
Section titled “Implementación”import { createSignal, onMount, onCleanup } from "solid-js";
export function useActiveTenant(onChange?: (id: string) => void) { const [tenantId, setTenantId] = createSignal<string>( typeof localStorage !== "undefined" ? (localStorage.getItem("activeTenant") ?? "") : "" );
onMount(() => { const handler = (e: Event) => { const { id } = (e as CustomEvent<{ id: string }>).detail; setTenantId(id); onChange?.(id); }; window.addEventListener("tenant:changed", handler); onCleanup(() => window.removeEventListener("tenant:changed", handler)); });
return { tenantId };}Uso típico
Section titled “Uso típico”import { useActiveTenant } from "@/lib/hooks/useActiveTenant";
const { tenantId } = useActiveTenant((id) => { setOffset(0); loadCatalogs(id); loadEmployees(id);});Patrones a respetar:
- Resetear paginación (
setOffset(0)) antes de recargar — el offset anterior pertenece al tenant anterior. - No pasar
tenantId()a fetch dentro del callback — al dispararse,tenantId()aún no se ha actualizado. Usar eliddel argumento. - El callback no se ejecuta en el primer montaje; sólo cuando el evento se emite. Si la island necesita una carga inicial, debe invocarla explícitamente con
tenantId().
useActivePeriod
Section titled “useActivePeriod”Suscriptor al evento period:changed que además escucha el evento nativo storage para sincronizarse cuando otra pestaña del navegador cambia el período. Normaliza valores (year en [2000, 2100], month en [1, 12]) y devuelve null para entradas inválidas.
interface ActivePeriod { year: number | null; month: number | null;}
function useActivePeriod(onChange?: (p: ActivePeriod) => void): { period: Accessor<ActivePeriod>;};Implementación
Section titled “Implementación”import { createSignal, onMount, onCleanup } from "solid-js";
export interface ActivePeriod { year: number | null; month: number | null;}
export function useActivePeriod(onChange?: (p: ActivePeriod) => void) { const [period, setPeriod] = createSignal<ActivePeriod>(readFromStorage());
onMount(() => { const applyPeriod = (next: ActivePeriod) => { setPeriod(next); onChange?.(next); };
const handler = (e: Event) => { applyPeriod(normalizePeriod((e as CustomEvent<ActivePeriod>).detail)); };
const storageHandler = (e: StorageEvent) => { if (e.key !== "activePeriodYear" && e.key !== "activePeriodMonth" && e.key !== null) { return; } applyPeriod(readFromStorage()); };
window.addEventListener("period:changed", handler); window.addEventListener("storage", storageHandler); onCleanup(() => { window.removeEventListener("period:changed", handler); window.removeEventListener("storage", storageHandler); }); });
return { period };}readFromStorage() y normalizePeriod() son helpers internos que aplican la validación [2000–2100] / [1–12]. El detalle completo está en el archivo fuente.
Uso típico
Section titled “Uso típico”import { useActivePeriod } from "@/lib/hooks/useActivePeriod";
const { period } = useActivePeriod((p) => { if (p.year && p.month) void loadEstadoMensual(p.year, p.month);});Casos a contemplar:
period().yearoperiod().monthpueden sernull(storage vacío o valor fuera de rango). Validar antes de hacer fetch.- El handler de
storagese dispara en otras pestañas, no en la actual. Es una capa adicional de sincronización para cuando el usuario tiene varias pestañas de Sevastopol abiertas.
useTenants
Section titled “useTenants”Loader canónico de la lista de tenants disponibles para el usuario autenticado. A diferencia de los dos anteriores, no escucha eventos: fetchea bajo demanda y persiste la selección.
function useTenants(): { tenants: Accessor<Tenant[]>; selectedTenant: Accessor<Tenant | null>; tenantId: Accessor<string>; isLoading: Accessor<boolean>; error: Accessor<string | null>; fetchTenants: () => Promise<void>; changeTenant: (id: string, callback?: (id: string) => void) => void;};Implementación
Section titled “Implementación”import { createSignal } from "solid-js";import { type Tenant } from "@/components/atoms";import { authenticatedFetch } from "@/lib/authFetch";
const TENANTS_API = "/api/tenant";
export function useTenants() { const [tenants, setTenants] = createSignal<Tenant[]>([]); const [selectedTenant, setSelectedTenant] = createSignal<Tenant | null>(null); const [isLoading, setIsLoading] = createSignal(true); const [error, setError] = createSignal<string | null>(null);
const tenantId = () => selectedTenant()?.id ?? "";
async function fetchTenants() { setIsLoading(true); try { const r = await authenticatedFetch(TENANTS_API); if (!r.ok) throw new Error("Tenants fetch failed"); const data: Tenant[] = await r.json(); setTenants(data);
const remembered = localStorage.getItem("activeTenant"); const first = data.find(t => t.id === remembered) ?? data[0] ?? null; if (first) setSelectedTenant(first); } catch (err: any) { setError(err.message || "Error al cargar organizaciones"); } finally { setIsLoading(false); } }
function changeTenant(id: string, callback?: (id: string) => void) { const t = tenants().find(tt => tt.id === id); if (t) { setSelectedTenant(t); localStorage.setItem("activeTenant", t.id); callback?.(t.id); } }
return { tenants, selectedTenant, tenantId, isLoading, error, fetchTenants, changeTenant };}Decisión: fetch manual
Section titled “Decisión: fetch manual”fetchTenants() no se invoca automáticamente desde el hook. El consumidor debe llamarlo dentro de onMount. Esto permite que islands que aún no necesitan la lista no paguen el costo de la petición.
import { onMount } from "solid-js";import { useTenants } from "@/lib/hooks/useTenants";
const { tenants, selectedTenant, fetchTenants, changeTenant, isLoading } = useTenants();
onMount(() => void fetchTenants());Selección persistente
Section titled “Selección persistente”Al recibir la lista de /api/tenant, el hook auto-selecciona en este orden:
- El tenant cuyo
idcoincide conlocalStorage.activeTenant. - El primer tenant del array.
nullsi la lista está vacía.
changeTenant(id, callback?) actualiza la señal, persiste en localStorage e invoca el callback opcional con el id recién seleccionado. No emite tenant:changed — esa responsabilidad es exclusiva de TenantSelectorBar para evitar bucles.
Contrato de eventos
Section titled “Contrato de eventos”Los hooks anteriores son consumidores de un contrato emitido por islands/core/TenantSelectorBar.tsx. Cualquier código que quiera notificar un cambio de tenant o período debe respetar este contrato exacto:
tenant:changed
Section titled “tenant:changed”window.dispatchEvent( new CustomEvent("tenant:changed", { detail: { id: string; tenant: Tenant }, }));useActiveTenant sólo lee detail.id. El campo tenant completo se incluye para consumidores que quieran nombre, RUT u otros campos sin re-buscar.
period:changed
Section titled “period:changed”window.dispatchEvent( new CustomEvent("period:changed", { detail: { year: number | null; month: number | null }, }));useActivePeriod normaliza el detail recibido — si el emisor envía strings o valores fuera de rango, el hook los convierte a null antes de propagar.
Claves en localStorage
Section titled “Claves en localStorage”| Clave | Tipo | Escrito por | Leído por |
|---|---|---|---|
activeTenant | string | TenantSelectorBar, useTenants.changeTenant | useActiveTenant, useTenants.fetchTenants |
activePeriodYear | string | TenantSelectorBar | useActivePeriod |
activePeriodMonth | string | TenantSelectorBar | useActivePeriod |
Patrón canónico en una island
Section titled “Patrón canónico en una island”Combinación habitual en una vista que depende de tenant y período:
import { onMount } from "solid-js";import { useActiveTenant } from "@/lib/hooks/useActiveTenant";import { useActivePeriod } from "@/lib/hooks/useActivePeriod";
export default function GastosIsland() { const { tenantId } = useActiveTenant((id) => { setOffset(0); void loadGastos(id, period()); });
const { period } = useActivePeriod((p) => { if (tenantId()) void loadGastos(tenantId(), p); });
onMount(() => { if (tenantId()) void loadGastos(tenantId(), period()); });
return <IslandBase module="CONTABILIDAD" title="Gastos">{/* ... */}</IslandBase>;}Reglas:
- Carga inicial explícita en
onMount— los callbacks de los hooks sólo reaccionan a cambios posteriores. - Cada callback debe leer el otro contexto desde su getter (
period()desde el callback de tenant,tenantId()desde el callback de período). En el momento del evento ambos getters reflejan el estado actual. - Resetear estado dependiente (paginación, filtros, selección) antes de recargar — pertenece al contexto anterior.