UNPKG

@stainless-code/vue-layers

Version:

Vue adapter for @stainless-code/layers — call a layer like an async function and await its response.

732 lines (680 loc) 20.3 kB
import type { DataTag, DefaultLayerError, ErrorOf, InferValidatorOutput, LayerCallContext, LayerComponentProps, LayerGroupOptions, LayerHandle, LayerKey, LayerOptions, LayerStack, LayerState, OmitKeyof, OpenLayerOptions, ResponseOf, ValidatedLayerHandle, Validator, } from "@stainless-code/layers"; import { childStackId, createCallContext, createLayer, createLayerGroup, keySignature, shallowArrayEqual, LayerClient, } from "@stainless-code/layers"; import { computed, defineComponent, Fragment, h, inject, onScopeDispose, provide, shallowRef, watch, } from "vue"; import type { Component, ComputedRef, InjectionKey, PropType, Ref, SlotsType, } from "vue"; export * from "@stainless-code/layers"; const LAYER_CLIENT_KEY: InjectionKey<LayerClient> = Symbol("layers.client"); /** * Provides a {@link LayerClient} to descendant components. * * @param client Client to provide. A new client is created when omitted. * @returns The provided client. */ export function provideLayerClient(client?: LayerClient): LayerClient { const c = client ?? new LayerClient(); provide(LAYER_CLIENT_KEY, c); return c; } /** * Reads the nearest {@link LayerClient} from Vue injection context. * * @returns The nearest provided client. */ export function useLayerClient(): LayerClient { const c = inject(LAYER_CLIENT_KEY, undefined); if (!c) { throw new Error( "[layers/vue] No LayerClient provided — call provideLayerClient() in a parent setup().", ); } return c; } function defaultSelector(states: LayerState[]): LayerState[] { return states; } type NoValidateOptions<Opts> = Opts extends { validate: Validator<unknown> } ? never : Opts; export interface UseStackOptions<T = LayerState[]> { stack?: string; select?: (states: LayerState[]) => T; compare?: (a: T, b: T) => boolean; } export interface UseLayerStateOptions< Key extends LayerKey, P = unknown, D = unknown, U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[], > { key: Key; stack?: string; select?: (states: LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[]) => U; compare?: (a: U, b: U) => boolean; } export type WiredLayerHandle< P, R, E = DefaultLayerError, D = unknown, RP = unknown, > = LayerHandle<P, R, E, D, RP> & { state: Readonly<Ref<LayerState<P, R, E, D>[]>>; queued: Readonly<Ref<LayerState<P, R, E, D>[]>>; top: Readonly<Ref<LayerState<P, R, E, D> | null>>; }; export type WiredValidatedLayerHandle< V extends Validator<unknown>, R, E = DefaultLayerError, D = unknown, RP = unknown, > = ValidatedLayerHandle<V, R, E, D, RP> & { state: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D>[]>>; queued: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D>[]>>; top: Readonly<Ref<LayerState<InferValidatorOutput<V>, R, E, D> | null>>; }; /** * Shared snapshot subscription primitive for mounted and queued stack hooks. * * Selector output is memoized against the stable snapshot reference so refs do * not churn object or array selections. */ function useSnapshot<T>( stack: LayerStack, getSource: () => LayerState[], select: (states: LayerState[]) => T, compare: (a: T, b: T) => boolean, ): Readonly<Ref<T>> { let cache: { base: LayerState[]; value: T } | null = null; const runSelect = (base: LayerState[]): T => { const prev = cache; if (prev && prev.base === base) return prev.value; const next = select(base); if (prev && compare(prev.value, next)) { cache = { base, value: prev.value }; return prev.value; } cache = { base, value: next }; return next; }; const ref = shallowRef<T>(runSelect(getSource())); const unsubscribe = stack.subscribe(() => { const prev = ref.value; const next = runSelect(getSource()); if (!compare(prev, next)) { ref.value = next; } }); onScopeDispose(unsubscribe); return ref; } /** * Exposes a {@link LayerClient} stack as a readonly Vue ref. * * Call it inside `setup()` or an `effectScope()` so `onScopeDispose` can clean * up the stack subscription. * * @param opts Options bag: `stack`, `select`, `compare`. * @param client Client to observe. Omit it to use {@link useLayerClient}. * @returns A readonly ref of the selected stack value. * @default `stack` is `"default"`; `select` is identity; `compare` is * `Object.is`. */ export function useStack<T = LayerState[]>( opts: UseStackOptions<T> = {}, client?: LayerClient, ): Readonly<Ref<T>> { const resolved = client ?? useLayerClient(); const stack = resolved.getStack(opts.stack ?? "default"); const select = opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T); const compare = opts.compare ?? Object.is; return useSnapshot(stack, () => stack.getSnapshot(), select, compare); } /** Exposes a stack's queued snapshot as a readonly Vue ref. */ export function useQueuedStack<T = LayerState[]>( opts: UseStackOptions<T> = {}, client?: LayerClient, ): Readonly<Ref<T>> { const resolved = client ?? useLayerClient(); const stack = resolved.getStack(opts.stack ?? "default"); const select = opts.select ?? (defaultSelector as unknown as (states: LayerState[]) => T); const compare = opts.compare ?? Object.is; return useSnapshot(stack, () => stack.getQueuedSnapshot(), select, compare); } /** * Observe all mounted layers matching a key. * * A {@link DataTag} key infers its response and error types. */ export function useLayerState< Key extends LayerKey, P = unknown, D = unknown, U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[], >( opts: UseLayerStateOptions<Key, P, D, U>, client?: LayerClient, ): Readonly<Ref<U>> { const sig = keySignature(opts.key); return useStack<U>( { stack: opts.stack, select: (states) => { const filtered = states.filter( (s) => keySignature(s.key) === sig, ) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[]; return opts.select ? opts.select(filtered) : (filtered as unknown as U); }, compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean), }, client, ); } /** Observe all queued layers matching a key. */ export function useLayerQueuedState< Key extends LayerKey, P = unknown, D = unknown, U = LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[], >( opts: UseLayerStateOptions<Key, P, D, U>, client?: LayerClient, ): Readonly<Ref<U>> { const sig = keySignature(opts.key); return useQueuedStack<U>( { stack: opts.stack, select: (states) => { const filtered = states.filter( (s) => keySignature(s.key) === sig, ) as LayerState<P, ResponseOf<Key>, ErrorOf<Key>, D>[]; return opts.select ? opts.select(filtered) : (filtered as unknown as U); }, compare: opts.compare ?? (shallowArrayEqual as (a: U, b: U) => boolean), }, client, ); } function useLayerImpl<P, R, E = DefaultLayerError, D = unknown, RP = unknown>( options: LayerOptions<P, R, E, D, RP> & { key: LayerKey }, client?: LayerClient, ): WiredLayerHandle<P, R, E, D, RP> & { top: ComputedRef<LayerState<P, R, E, D> | null>; } { const resolved = client ?? useLayerClient(); const stackId = options.stack ?? "default"; const sig = keySignature(options.key); const selectByKey = (states: LayerState[]) => states.filter((s) => keySignature(s.key) === sig) as LayerState< P, R, E, D >[]; const state = useStack<LayerState<P, R, E, D>[]>( { stack: stackId, select: selectByKey, compare: shallowArrayEqual }, resolved, ); const queued = useQueuedStack<LayerState<P, R, E, D>[]>( { stack: stackId, select: selectByKey, compare: shallowArrayEqual }, resolved, ); const top = computed(() => state.value.at(-1) ?? null); const base = createLayer(options, resolved); return { ...base, get current() { return base.current; }, state, queued, top, }; } /** Wired handle: `createLayer` + reactive `state`/`queued`/`top`. */ export function useLayer< V extends Validator<unknown>, R, E = DefaultLayerError, D = unknown, RP = unknown, >( options: LayerOptions<InferValidatorOutput<V>, R, E, D, RP> & { key: LayerKey; validate: V; }, client?: LayerClient, ): WiredValidatedLayerHandle<V, R, E, D, RP>; export function useLayer< P, R, E = DefaultLayerError, D = unknown, RP = unknown, >( options: NoValidateOptions<LayerOptions<P, R, E, D, RP> & { key: LayerKey }>, client?: LayerClient, ): WiredLayerHandle<P, R, E, D, RP>; export function useLayer< P, R, E = DefaultLayerError, D = unknown, RP = unknown, >( options: LayerOptions<P, R, E, D, RP> & { key: LayerKey }, client?: LayerClient, ): WiredLayerHandle<P, R, E, D, RP> { return useLayerImpl(options, client); } type AnyComponent = Component<LayerComponentProps<never, never, never, never>>; /** Render every active layer in a stack with its registered component. */ export const StackOutlet = defineComponent({ name: "StackOutlet", props: { stack: { type: String, default: "default" }, rootProps: { type: null, default: undefined }, }, setup(props) { const client = useLayerClient(); const stk = client.getStack(props.stack); const states = useStack({ stack: props.stack }); return () => h( Fragment, states.value.map((s) => { const layer = stk.getLayer(s.id); const Component = layer?.component as AnyComponent | undefined; if (!layer || !Component) { if (process.env.NODE_ENV !== "production") { console.warn( `[layers/vue] No component for layer ${s.id} (key ${JSON.stringify(s.key)}); StackOutlet renders nothing. Provide a \`component\` or use useStack.`, ); } return null; } const call = createCallContext(stk, layer, s, props.rootProps); return h(Component, { key: s.id, call: call as never, payload: s.payload as never, data: s.data as never, error: s.error as never, phase: s.phase, transition: s.transition, actionStatus: s.actionStatus, dismissing: s.dismissing, }); }), ); }, }); export interface StackSubscribeProps<T = LayerState[]> { stack?: string; selector: (states: LayerState[]) => T; } /** * Render a selected stack value through a default scoped slot (cross-adapter * parity). The slot payload is `{ value: unknown }` — `defineComponent` can't * thread the selector's return type through a slot; prefer * `useStack({ stack, select })` in `setup()` for a fully-typed value. * * @example * ```vue * <StackSubscribe :selector="(s) => s.length"> * <template #default="{ value }">{{ value }} open</template> * </StackSubscribe> * ``` */ export const StackSubscribe = defineComponent({ name: "StackSubscribe", props: { stack: { type: String, default: "default" }, selector: { type: Function as PropType<(states: LayerState[]) => unknown>, required: true, }, }, slots: Object as SlotsType<{ default: (payload: { value: unknown }) => unknown; }>, setup(props, { slots }) { const value = useStack({ stack: props.stack, select: props.selector }); return () => slots.default?.({ value: value.value }); }, }); export interface StackHandles { states: Readonly<Ref<LayerState[]>>; getCall: (state: LayerState) => LayerCallContext<unknown, unknown>; } /** Return the states and call contexts needed to render a stack headlessly. */ export function useStackHandles( stack = "default", rootProps?: unknown, ): StackHandles { const client = useLayerClient(); const stk = client.getStack(stack); const states = useStack({ stack }); const getCall = ( state: LayerState, ): LayerCallContext<unknown, unknown, unknown> => { const layer = stk.getLayer(state.id); return createCallContext(stk, layer!, state, rootProps) as LayerCallContext< unknown, unknown, unknown >; }; return { states, getCall }; } export interface MutationRun<R> { /** On success, end the layer with `response`; on failure, leave it open and rethrow. */ orEnd: (response: R) => Promise<void>; } export interface MutationFlow<R> { /** True while a `run(...)` async action is in flight. Mirrors the layer's `actionStatus: "running"`. */ pending: Readonly<Ref<boolean>>; run: (fn: () => Promise<void> | void) => MutationRun<R>; } /** * Coordinate a layer's pending state with an async mutation and end it on success. * * @example * ```vue * <script setup lang="ts"> * import { type LayerComponentProps, useMutationFlow } from "@stainless-code/vue-layers"; * * const props = defineProps<LayerComponentProps<void, boolean>>(); * const flow = useMutationFlow(props.call); * </script> * * <template> * <button * :disabled="flow.pending.value" * @click="flow.run(() => Promise.resolve()).orEnd(true)" * > * Confirm * </button> * </template> * ``` */ export function useMutationFlow<P, R, RootProps = unknown>( call: LayerCallContext<P, R, RootProps>, ): MutationFlow<R> { const pending = shallowRef(false); const run = (fn: () => Promise<void> | void): MutationRun<R> => ({ orEnd: async (response: R) => { pending.value = true; call.setRunning(true); try { await fn(); call.end(response); } finally { call.setRunning(false); pending.value = false; } }, }); return { pending, run }; } /** Open a layer on a pre-bound stack, with {@link DataTag} response and error inference. */ export interface ScopedOpen { <P, R, E = DefaultLayerError, D = unknown, RootProps = unknown>( options: OmitKeyof< OpenLayerOptions<P, R, E, D, RootProps> & { key: DataTag<LayerKey, R, E>; }, "stack" | "validate" >, ): Promise<R>; <P, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown>( options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "stack">, ): Promise<R>; } export interface LayerGroup { open: ScopedOpen; dismissAll: (response?: unknown) => void; states: Readonly<Ref<LayerState[]>>; /** Renders the child stack inline — place inside the parent layer's DOM. */ Outlet: Component<{ rootProps?: unknown }>; stackId: string; } /** * Create a child stack scoped to the calling layer's lifetime. * * The child stack is disposed and cleared via `cancelAll` when its parent * layer unmounts (`LayerCancelledError`). */ export function useLayerGroup<P, R, RootProps = unknown>( call: LayerCallContext<P, R, RootProps>, options?: LayerGroupOptions, ): LayerGroup { const client = useLayerClient(); const stackId = childStackId(call, options?.name); const group = createLayerGroup(client, call, options); onScopeDispose(() => { group.dispose(); client.cancelAll(group.stackId, { reason: "groupDispose" }); }); const states = useStack({ stack: stackId }); const open = < P2, R2 = void, E = DefaultLayerError, D = unknown, RP = unknown, >( opts: OmitKeyof<OpenLayerOptions<P2, R2, E, D, RP>, "stack">, ) => client.open({ ...opts, stack: stackId, } as OpenLayerOptions<P2, R2, E, D, RP>); const dismissAll = (response?: unknown) => client.dismissAll(stackId, response); const Outlet = defineComponent({ name: "LayerGroupOutlet", props: { rootProps: { type: null, default: undefined }, }, setup(props) { return () => h(StackOutlet, { stack: stackId, rootProps: props.rootProps }); }, }); return { open: open as unknown as ScopedOpen, dismissAll, states, Outlet, stackId, }; } export interface AppStack { open: ScopedOpen; dismissAll: (response?: unknown) => void; states: Readonly<Ref<LayerState[]>>; } export interface AppLayerProps<P, R = void> { /** Layer definition with the stack supplied by the factory. */ options: OmitKeyof<LayerOptions<P, R>, "stack">; /** Controlled visibility. `true` opens the layer; `false` dismisses it. */ open: boolean; payload: P; /** Called when the layer resolves. */ onResolved?: (response: R) => void; } export interface StackHook<HostProps> { StackProvider: Component<{ client?: LayerClient }>; useAppStack: () => AppStack; /** Host props are forwarded to `config.Host` via fallthrough attrs. */ AppHost: Component<HostProps>; AppLayer: <P, R = void>(props: AppLayerProps<P, R>) => null; } /** Create provider, host, controlled-layer, and access hooks bound to one stack. */ export function createStackHook<HostProps extends object = object>( config: { client?: LayerClient; stack?: string; Host?: Component<{ default?: unknown } & HostProps>; } = {}, ): StackHook<HostProps> { const stackId = config.stack ?? "default"; const StackProvider = defineComponent({ name: "StackProvider", props: { client: { type: Object as PropType<LayerClient>, default: undefined, }, }, setup(props, { slots }) { provideLayerClient(props.client ?? config.client); return () => slots.default?.(); }, }); function useAppStack(): AppStack { const client = useLayerClient(); const states = useStack({ stack: stackId }); const open = < P, R = void, E = DefaultLayerError, D = unknown, RootProps = unknown, >( options: OmitKeyof<OpenLayerOptions<P, R, E, D, RootProps>, "stack">, ) => client.open({ ...options, stack: stackId, } as OpenLayerOptions<P, R, E, D, RootProps>); const dismissAll = (response?: unknown) => client.dismissAll(stackId, response); return { open: open as unknown as ScopedOpen, dismissAll, states }; } const AppHost = defineComponent({ name: "AppHost", inheritAttrs: false, setup(_, { attrs }) { return () => { const outlet = h(StackOutlet, { stack: stackId, rootProps: attrs }); return config.Host ? h(config.Host as Component, attrs, { default: () => outlet }) : outlet; }; }, }); const AppLayerComponent = defineComponent({ name: "AppLayer", props: { options: { type: Object as PropType< OmitKeyof<LayerOptions<unknown, unknown>, "stack"> >, required: true, }, open: { type: Boolean, required: true }, payload: { type: null, default: undefined }, onResolved: { type: Function as PropType<(response: unknown) => void>, default: undefined, }, }, setup(props) { const client = useLayerClient(); let opened = false; watch( () => props.open, (isOpen) => { if (isOpen && !opened) { opened = true; void client .open({ ...props.options, stack: stackId, payload: props.payload, } as OpenLayerOptions<unknown, unknown>) .then((response) => { opened = false; props.onResolved?.(response); }); } else if (!isOpen && opened) { const stack = client.getStack(stackId); const layer = stack.find(props.options.key); if (layer) { stack.dismiss(layer, undefined); } opened = false; } }, { immediate: true }, ); onScopeDispose(() => { if (opened) { const stack = client.getStack(stackId); const layer = stack.find(props.options.key); if (layer) { stack.dismiss(layer, undefined); } opened = false; } }); return () => null; }, }); const AppLayer = AppLayerComponent as unknown as <P, R = void>( props: AppLayerProps<P, R>, ) => null; return { StackProvider, useAppStack, AppHost: AppHost as unknown as Component<HostProps>, AppLayer, }; }