UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

670 lines (669 loc) 21.3 kB
import { $module, Alepha, AlephaError, Atom } from "alepha"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { jsx } from "react/jsx-runtime"; import { DateTimeProvider } from "alepha/datetime"; import { LinkProvider } from "alepha/server/links"; //#region ../../src/react/core/components/ClientOnly.tsx /** * A small utility component that renders its children only on the client side. * * Optionally, you can provide a fallback React node that will be rendered. * * You should use this component when * - you have code that relies on browser-specific APIs * - you want to avoid server-side rendering for a specific part of your application * - you want to prevent pre-rendering of a component * * @example * ```tsx * import { ClientOnly } from "alepha/react"; * * const MyComponent = () => { * // Avoids SSR issues with Date API * return ( * <ClientOnly> * {new Date().toLocaleTimeString()} * </ClientOnly> * ); * } * ``` */ const ClientOnly = (props) => { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (props.disabled) return props.children; return mounted ? props.children : props.fallback; }; //#endregion //#region ../../src/react/core/components/ErrorBoundary.tsx /** * A reusable error boundary for catching rendering errors in any part of the React component tree. * * It's already included in the Alepha React framework when using page or layout components. */ var ErrorBoundary = class extends React.Component { constructor(props) { super(props); this.state = {}; } /** * Update state so the next render shows the fallback UI. */ static getDerivedStateFromError(error) { return { error }; } /** * Lifecycle method called when an error is caught. * You can log the error or perform side effects here. */ componentDidCatch(error, info) { if (this.props.onError) this.props.onError(error, info); } /** * Recover once the caller signals the failing input is gone. */ componentDidUpdate(prevProps) { if (!this.state.error) return; if (this.hasResetKeyChanged(prevProps.resetKeys, this.props.resetKeys)) this.setState({ error: void 0 }); } hasResetKeyChanged(previous, next) { if (previous === next) return false; if (!previous || !next || previous.length !== next.length) return true; return previous.some((value, i) => !Object.is(value, next[i])); } render() { if (this.state.error) { const reset = () => { this.setState({ error: void 0 }); }; return this.props.fallback(this.state.error, reset); } return this.props.children; } }; //#endregion //#region ../../src/react/core/contexts/AlephaContext.ts /** * React context to provide the Alepha instance throughout the component tree. */ const AlephaContext = createContext(void 0); //#endregion //#region ../../src/react/core/contexts/AlephaProvider.tsx /** * AlephaProvider component to initialize and provide Alepha instance to the app. * * This isn't recommended for apps using `alepha/react/router`, as Router will handle this for you. */ const AlephaProvider = (props) => { const alepha = useMemo(() => Alepha.create(), []); const [started, setStarted] = useState(false); const [error, setError] = useState(); useEffect(() => { alepha.start().then(() => setStarted(true)).catch((err) => setError(err)); }, [alepha]); if (error) return props.onError(error); if (!started) return props.onLoading(); return /* @__PURE__ */ jsx(AlephaContext.Provider, { value: alepha, children: props.children }); }; //#endregion //#region ../../src/react/core/hooks/useAlepha.ts /** * Main Alepha hook. * * It provides access to the Alepha instance within a React component. * * With Alepha, you can access the core functionalities of the framework: * * - alepha.state() for state management * - alepha.inject() for dependency injection * - alepha.events.emit() for event handling * etc... */ const useAlepha = () => { const alepha = useContext(AlephaContext); if (!alepha) throw new AlephaError("Hook 'useAlepha()' must be used within an AlephaContext.Provider"); return alepha; }; //#endregion //#region ../../src/react/core/hooks/useInject.ts /** * Hook to inject a service instance. * It's a wrapper of `useAlepha().inject(service)` with a memoization. */ const useInject = (service) => { const alepha = useAlepha(); return useMemo(() => alepha.inject(service), []); }; //#endregion //#region ../../src/react/core/hooks/useAction.ts /** * Hook for handling async actions with automatic error handling and event emission. * * By default, prevents concurrent executions - if an action is running and you call it again, * the second call will be ignored. Use `debounce` option to delay execution instead. * * Emits lifecycle events: * - `react:action:begin` - When action starts * - `react:action:success` - When action completes successfully * - `react:action:error` - When action throws an error * - `react:action:end` - Always emitted at the end * * @example Basic usage * ```tsx * const action = useAction({ * handler: async (data) => { * await api.save(data); * } * }, []); * * <button onClick={() => action.run(data)} disabled={action.loading}> * Save * </button> * ``` * * @example With debounce (search input) * ```tsx * const search = useAction({ * handler: async (query: string) => { * await api.search(query); * }, * debounce: 300 // Wait 300ms after last call * }, []); * * <input onChange={(e) => search.run(e.target.value)} /> * ``` * * @example Run on component mount * ```tsx * const fetchData = useAction({ * handler: async () => { * const data = await api.getData(); * return data; * }, * runOnInit: true // Runs once when component mounts * }, []); * ``` * * @example Run periodically (polling) * ```tsx * const pollStatus = useAction({ * handler: async () => { * const status = await api.getStatus(); * return status; * }, * runEvery: 5000 // Run every 5 seconds * }, []); * * // Or with duration tuple * const pollStatus = useAction({ * handler: async () => { * const status = await api.getStatus(); * return status; * }, * runEvery: [30, 'seconds'] // Run every 30 seconds * }, []); * ``` * * @example With AbortController * ```tsx * const fetch = useAction({ * handler: async (url, { signal }) => { * const response = await fetch(url, { signal }); * return response.json(); * } * }, []); * // Automatically cancelled on unmount or when new request starts * ``` * * @example With error handling * ```tsx * const deleteAction = useAction({ * handler: async (id: string) => { * await api.delete(id); * }, * onError: (error) => { * if (error.code === 'NOT_FOUND') { * // Custom error handling * } * } * }, []); * * {deleteAction.error && <div>Error: {deleteAction.error.message}</div>} * ``` * * @example Global error handling * ```tsx * // In your root app setup * alepha.events.on("react:action:error", ({ error }) => { * toast.danger(error.message); * Sentry.captureException(error); * }); * ``` */ function useAction(options, deps) { const alepha = useAlepha(); const dateTimeProvider = useInject(DateTimeProvider); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [result, setResult] = useState(); const isExecutingRef = useRef(false); const debounceTimerRef = useRef(void 0); const abortControllerRef = useRef(void 0); const isMountedRef = useRef(true); const intervalRef = useRef(void 0); const runIdRef = useRef(0); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; if (debounceTimerRef.current) { dateTimeProvider.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = void 0; } if (intervalRef.current) { dateTimeProvider.clearInterval(intervalRef.current); intervalRef.current = void 0; } if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = void 0; } isExecutingRef.current = false; }; }, []); const executeAction = useCallback(async (args, { supersede = false } = {}) => { if (isExecutingRef.current && !supersede) return; if (abortControllerRef.current) abortControllerRef.current.abort(); const abortController = new AbortController(); abortControllerRef.current = abortController; const runId = ++runIdRef.current; const isLatestRun = () => runIdRef.current === runId; isExecutingRef.current = true; setLoading(true); setError(void 0); try { await alepha.events.emit("react:action:begin", { type: "custom", id: options.id }); const result = await options.handler(...args, { signal: abortController.signal }); if (!isMountedRef.current || abortController.signal.aborted || !isLatestRun()) return; setResult(result); await alepha.events.emit("react:action:success", { type: "custom", id: options.id }); if (options.onSuccess) await options.onSuccess(result); return result; } catch (err) { if (err instanceof Error && err.name === "AbortError") return; if (!isMountedRef.current || !isLatestRun()) return; const error = err; setError(error); await alepha.events.emit("react:action:error", { type: "custom", id: options.id, error }); if (options.onError) await options.onError(error); } finally { if (isLatestRun()) { isExecutingRef.current = false; if (isMountedRef.current) setLoading(false); } await alepha.events.emit("react:action:end", { type: "custom", id: options.id }); if (abortControllerRef.current === abortController) abortControllerRef.current = void 0; } }, [ ...deps, options.id, options.onError, options.onSuccess ]); const runAction = useCallback(async (args, options_ = {}) => { if (options.debounce) { if (debounceTimerRef.current) dateTimeProvider.clearTimeout(debounceTimerRef.current); return new Promise((resolve) => { debounceTimerRef.current = dateTimeProvider.createTimeout(async () => { resolve(await executeAction(args, options_)); }, options.debounce ?? 0); }); } return executeAction(args, options_); }, [executeAction, options.debounce]); /** * Public `run()` — a user-initiated call. Deduped while one is in flight so a * double-clicked mutation submits once. */ const handler = useCallback((...args) => runAction(args), [runAction]); const cancel = useCallback(() => { if (debounceTimerRef.current) { dateTimeProvider.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = void 0; } if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = void 0; } if (isMountedRef.current) { isExecutingRef.current = false; setLoading(false); } }, []); useEffect(() => { if (options.runOnInit) runAction([], { supersede: true }); }, deps); useEffect(() => { if (!options.runEvery) return; intervalRef.current = dateTimeProvider.createInterval(() => runAction([], { supersede: true }), options.runEvery, true); return () => { if (intervalRef.current) { dateTimeProvider.clearInterval(intervalRef.current); intervalRef.current = void 0; } }; }, [runAction, options.runEvery]); return { run: handler, loading, error, cancel, result }; } //#endregion //#region ../../src/react/core/hooks/useClient.ts /** * Hook to get a virtual client for the specified scope. * * It's the React-hook version of `$client()`, from `AlephaServerLinks` module. */ const useClient = (scope) => { const linkProvider = useInject(LinkProvider); return useMemo(() => { return linkProvider.client(scope); }, [scope]); }; //#endregion //#region ../../src/react/core/hooks/useComputed.ts /** * Subscribes to a `$computed` value. * * `getSnapshot` always reads the dependency atoms **live** off the store — * never through a subscription-set dirty flag. A dirty flag is only ever * flipped by the `state:mutate` listener registered in `subscribe`, which * React wires up in a passive effect (after commit); `EventManager.emit` is * async on top of that. A mutation dispatched between the initial render and * that effect committing would be invisible to the flag, so `getSnapshot` * (including React's own post-subscribe consistency re-check) would keep * returning the stale pre-mutation value. See `useStore.ts` and * `useSelector.ts` for the same rationale. * * Referential stability for `useSyncExternalStore` comes from comparing the * *inputs* (the resolved values of the computed's transitive atom * dependencies) by identity, not from caching on a mount-time ref: when none * of the inputs changed since the last snapshot, the previous output * reference is returned as-is; when any input changed (or `target` itself * was swapped for a different `Computed`), the value is recomputed and a new * reference is cached. * * A `Computed` passed here should be declared at module scope, like an * atom — constructing one inline in a component body creates a new * instance every render, which (because `subscribe`/`getSnapshot` are keyed * on `target.key`, matching `useStore`/`useSelector`) is tolerated for * resubscription purposes, but any closures captured by that instance's * `get()` will be locked to whichever render first created the subscription * until `target.key` itself changes. * * ```tsx * const total = useComputed(cartTotal); * ``` */ function useComputed(target) { const alepha = useAlepha(); const key = target.key; const cache = useRef(null); const subscribe = useCallback((onStoreChange) => { if (!alepha.isBrowser()) return () => {}; const keys = new Set(target.keys()); return alepha.events.on("state:mutate", (ev) => { if (keys.has(ev.key)) onStoreChange(); }); }, [alepha, key]); const getSnapshot = useCallback(() => { const inputs = target.keys().map((depKey) => alepha.store.get(depKey)); const prev = cache.current; if (prev && prev.target === target && prev.inputs.length === inputs.length && prev.inputs.every((value, index) => Object.is(value, inputs[index]))) return prev.value; const value = alepha.store.get(target); cache.current = { target, inputs, value }; return value; }, [alepha, key]); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } //#endregion //#region ../../src/react/core/hooks/useEvents.ts /** * Allow subscribing to multiple Alepha events. See {@link Hooks} for available events. * * useEvents is fully typed to ensure correct event callback signatures. * * @example * ```tsx * useEvents( * { * "react:transition:begin": (ev) => { * console.log("Transition began to:", ev.to); * }, * "react:transition:error": { * priority: "first", * callback: (ev) => { * console.error("Transition error:", ev.error); * }, * }, * }, * [], * ); * ``` */ const useEvents = (opts, deps) => { const alepha = useAlepha(); useEffect(() => { if (!alepha.isBrowser()) return; const subs = []; for (const [name, hook] of Object.entries(opts)) subs.push(alepha.events.on(name, hook)); return () => { for (const clear of subs) clear(); }; }, deps); }; //#endregion //#region ../../src/react/core/hooks/useQuery.ts /** * Hook for declarative data fetching with automatic execution and refetch. * * Thin wrapper over {@link useAction}: it pre-applies `runOnInit: true`, * exposes the last result as `data`, and provides a stable `refetch()` to * re-run the query on demand. For optimistic mutations and side-effects, * use {@link useAction} directly — `useQuery` is for the read path. * * Caching, request deduplication, and AbortSignal cancellation come from * `useAction` + `HttpClient`. There is no separate cache layer — pass * `localCache` to your `HttpClient.fetch()`/`fetchAction()` call inside * the query handler if you want per-call caching. * * @example Basic * ```tsx * const client = useInject(HttpClient); * const { data, loading, error, refetch } = useQuery({ * handler: async ({ signal }) => { * const res = await client.fetch("/api/users", { request: { signal } }); * return res.data; * }, * }, []); * ``` * * @example Re-fetch when a dep changes * ```tsx * const { data } = useQuery({ * handler: async () => api.getUser(userId), * }, [userId]); * ``` * * @example Polling * ```tsx * const { data } = useQuery({ * handler: async () => api.getStatus(), * runEvery: [5, "seconds"], * }, []); * ``` */ function useQuery(options, deps) { const enabled = options.enabled !== false; const [data, setData] = useState(options.initialData); const settledRef = useRef(false); const action = useAction({ id: options.id, handler: options.handler, runOnInit: enabled, runEvery: options.runEvery, debounce: options.debounce, onError: options.onError, onSuccess: async (result) => { settledRef.current = true; setData(result); if (options.onSuccess) await options.onSuccess(result); } }, deps); const refetch = useCallback(() => action.run(), [action.run]); return { data, loading: action.loading || enabled && options.initialData === void 0 && !settledRef.current && action.error === void 0, error: action.error, refetch, cancel: action.cancel }; } //#endregion //#region ../../src/react/core/hooks/useSelector.ts /** * Subscribes to a slice of an atom's state. * * Unlike {@link useStore}, which re-renders on every mutation of the atom, * `useSelector` re-renders only when the selected slice actually changes * according to `equality` (default: `Object.is`). Pass {@link shallowEqual} * when the selector builds a fresh object on each call. * * ```tsx * const theme = useSelector(uiAtom, (s) => s.theme); * ``` */ function useSelector(target, select, equality = Object.is) { const alepha = useAlepha(); const key = target.key; const cache = useRef(null); const selectRef = useRef(select); selectRef.current = select; const equalityRef = useRef(equality); equalityRef.current = equality; const subscribe = useCallback((onStoreChange) => { if (!alepha.isBrowser()) return () => {}; return alepha.events.on("state:mutate", (ev) => { if (ev.key === key) onStoreChange(); }); }, [alepha, key]); const getSnapshot = useCallback(() => { const input = alepha.store.get(target); const prev = cache.current; if (prev && Object.is(prev.input, input)) return prev.selected; const selected = selectRef.current(input); if (prev && equalityRef.current(prev.selected, selected)) { cache.current = { input, selected: prev.selected }; return prev.selected; } cache.current = { input, selected }; return selected; }, [alepha, key]); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } //#endregion //#region ../../src/react/core/hooks/useStore.ts function useStore(target, defaultValue) { const alepha = useAlepha(); const key = target instanceof Atom ? target.key : target; useMemo(() => { if (defaultValue != null && alepha.store.get(target) == null) alepha.store.set(target, defaultValue); }, [defaultValue, key]); const subscribe = useCallback((onStoreChange) => { if (!alepha.isBrowser()) return () => {}; return alepha.events.on("state:mutate", (ev) => { if (ev.key === key) onStoreChange(); }); }, [alepha, key]); const getSnapshot = useCallback(() => alepha.store.get(target), [alepha, key]); return [useSyncExternalStore(subscribe, getSnapshot, getSnapshot), useCallback((value) => { alepha.store.set(target, value); }, [alepha, key])]; } //#endregion //#region ../../src/react/core/utils/shallowEqual.ts /** * Shallow structural equality over own enumerable keys. * * Intended as the `equality` argument of `useSelector` when the selector * builds a fresh object/array on every call (e.g. `(s) => ({ a: s.a })`). */ const shallowEqual = (a, b) => { if (Object.is(a, b)) return true; if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) return false; const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) if (!Object.hasOwn(b, key) || !Object.is(a[key], b[key])) return false; return true; }; //#endregion //#region ../../src/react/core/index.ts /** * Full-stack React framework with server-side rendering. * * **Features:** * - React page routes with type-safe params * - Async action handler with loading/error/cancel states * - Type-safe HTTP client access * - Dependency injection in components * - Global state management * - Router navigation methods * - Current route state access * - Check if path is active * - URL query parameters * - Access route schema * - Subscribe to Alepha events * - Type-safe form handling with validation * - Error handling wrapper component * - Client-side only rendering component * - Server-side rendering with hydration * - Automatic code splitting * - Event system for action tracking * * @module alepha.react */ const AlephaReact = $module({ name: "alepha.react.core" }); //#endregion export { AlephaContext, AlephaProvider, AlephaReact, ClientOnly, ErrorBoundary, shallowEqual, useAction, useAlepha, useClient, useComputed, useEvents, useInject, useQuery, useSelector, useStore }; //# sourceMappingURL=index.js.map