alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
670 lines (669 loc) • 21.3 kB
JavaScript
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