From 8efd2d4d2ee67a9e4ecc8bc7d094fa7fac86ca84 Mon Sep 17 00:00:00 2001 From: kingseonjin Date: Tue, 3 Mar 2026 12:42:15 +0900 Subject: [PATCH] feat(core): allow omitting context in initial state for optional context types (#181) --- .changeset/optional-initial-context.md | 8 ++++++++ docs/src/pages/docs/context-guide.en.mdx | 7 +------ docs/src/pages/docs/context-guide.ko.mdx | 7 +------ docs/src/pages/docs/features.en.mdx | 1 - docs/src/pages/docs/features.ko.mdx | 1 - docs/src/pages/docs/get-started.en.mdx | 2 -- docs/src/pages/docs/get-started.ko.mdx | 2 -- docs/src/pages/docs/migration.en.mdx | 2 -- docs/src/pages/docs/migration.ko.mdx | 4 ---- docs/src/pages/docs/overlay.en.mdx | 1 - docs/src/pages/docs/overlay.ko.mdx | 1 - docs/src/pages/docs/sub-funnel.en.mdx | 2 -- docs/src/pages/docs/sub-funnel.ko.mdx | 2 -- packages/core/src/core.ts | 6 ++++++ packages/core/src/stepBuilder.ts | 2 +- packages/core/src/useFunnel.tsx | 21 +++++++++++++++------ 16 files changed, 32 insertions(+), 37 deletions(-) create mode 100644 .changeset/optional-initial-context.md diff --git a/.changeset/optional-initial-context.md b/.changeset/optional-initial-context.md new file mode 100644 index 0000000..10004db --- /dev/null +++ b/.changeset/optional-initial-context.md @@ -0,0 +1,8 @@ +--- +'@use-funnel/core': minor +--- + +feat(core): make `initial.context` optional when all context properties are optional + +- `createFunnelSteps()` now defaults `TContext` to `{}` when no type argument is provided +- `useFunnel({ initial: { step: 'A' } })` is now valid when the context type has only optional properties (e.g. `{ foo?: string }` or `{}`) diff --git a/docs/src/pages/docs/context-guide.en.mdx b/docs/src/pages/docs/context-guide.en.mdx index bfa2910..2871929 100644 --- a/docs/src/pages/docs/context-guide.en.mdx +++ b/docs/src/pages/docs/context-guide.en.mdx @@ -29,7 +29,6 @@ function MyFunnelApp() { id: "how-to-define-step-contexts", initial: { step: "EmailInput", - context: {} } }); @@ -81,7 +80,6 @@ function MyFunnelApp() { id: "how-to-define-step-contexts", initial: { step: "EmailInput", - context: {} }, steps: { EmailInput: { guard: EmailInput_guard }, @@ -112,7 +110,7 @@ function MyFunnelApp() { You can create a step that changes a specific key of the initial context from optional to required when transitioning to the next step simply. ```tsx -declare function createFunnelSteps(): { +declare function createFunnelSteps(): { extends: (name: string | string[], options?: { requiredKeys: string | string[] }) => this; build: () => Record; } @@ -147,7 +145,6 @@ function MyFunnelApp() { id: "create-funnel-steps", initial: { step: "EmailInput", - context: {} }, steps }); @@ -190,7 +187,6 @@ function MyFunnelApp() { }, initial: { step: "EmailInput", - context: {} } }); @@ -235,7 +231,6 @@ function MyFunnelApp() { }, initial: { step: "EmailInput", - context: {} } }); diff --git a/docs/src/pages/docs/context-guide.ko.mdx b/docs/src/pages/docs/context-guide.ko.mdx index be17354..7582bb8 100644 --- a/docs/src/pages/docs/context-guide.ko.mdx +++ b/docs/src/pages/docs/context-guide.ko.mdx @@ -29,7 +29,6 @@ function MyFunnelApp() { id: "how-to-define-step-contexts", initial: { step: "이메일입력", - context: {} } }); @@ -81,7 +80,6 @@ function MyFunnelApp() { id: "step-by-step", initial: { step: "이메일입력", - context: {} }, steps: { 이메일입력: { guard: 이메일입력_guard }, @@ -112,7 +110,7 @@ function MyFunnelApp() { 간단하게 다음 step으로 넘어갈 때 초기 context의 특정 키를 선택(optional)에서 필수(required)로 변경하는 step을 만들 수 있어요. ```tsx -declare function createFunnelSteps(): { +declare function createFunnelSteps(): { extends: (name: string | string[], options?: { requiredKeys: string | string[] }) => this; build: () => Record; } @@ -149,7 +147,6 @@ function MyFunnelApp() { steps: steps, initial: { step: "이메일입력", - context: {} } }); @@ -191,7 +188,6 @@ function MyFunnelApp() { }, initial: { step: "이메일입력", - context: {} } }); @@ -238,7 +234,6 @@ function MyFunnelApp() { }, initial: { step: "이메일입력", - context: {} } }); diff --git a/docs/src/pages/docs/features.en.mdx b/docs/src/pages/docs/features.en.mdx index dfd205f..0b0055b 100644 --- a/docs/src/pages/docs/features.en.mdx +++ b/docs/src/pages/docs/features.en.mdx @@ -50,7 +50,6 @@ const funnel = useFunnel<{ id: "strongly-typed", initial: { step: "A", - context: {} } }); diff --git a/docs/src/pages/docs/features.ko.mdx b/docs/src/pages/docs/features.ko.mdx index f3aa13c..c0d2184 100644 --- a/docs/src/pages/docs/features.ko.mdx +++ b/docs/src/pages/docs/features.ko.mdx @@ -48,7 +48,6 @@ const funnel = useFunnel<{ id: "strongly-typed", initial: { step: "A", - context: {} } }); diff --git a/docs/src/pages/docs/get-started.en.mdx b/docs/src/pages/docs/get-started.en.mdx index 57e9d67..f6cac1b 100644 --- a/docs/src/pages/docs/get-started.en.mdx +++ b/docs/src/pages/docs/get-started.en.mdx @@ -82,7 +82,6 @@ function MyFunnelApp() { id: "my-funnel-app", initial: { step: "EmailInput", - context: {}, }, }); // ... @@ -105,7 +104,6 @@ function MyFunnelApp() { id: 'my-funnel-app', initial: { step: "EmailInput", - context: {}, }, }); // ... diff --git a/docs/src/pages/docs/get-started.ko.mdx b/docs/src/pages/docs/get-started.ko.mdx index 06f98f7..ba9b69f 100644 --- a/docs/src/pages/docs/get-started.ko.mdx +++ b/docs/src/pages/docs/get-started.ko.mdx @@ -80,7 +80,6 @@ function MyFunnelApp() { id: "my-funnel-app", initial: { step: "이메일입력", - context: {} } }); // ... @@ -104,7 +103,6 @@ function MyFunnelApp() { id: "my-funnel-app", initial: { step: "이메일입력", - context: {} } }); // ... diff --git a/docs/src/pages/docs/migration.en.mdx b/docs/src/pages/docs/migration.en.mdx index 2837eb3..9f9fcd8 100644 --- a/docs/src/pages/docs/migration.en.mdx +++ b/docs/src/pages/docs/migration.en.mdx @@ -44,7 +44,6 @@ const funnel = useFunnel({ steps, initial: { step: 'A', - context: {} } }); ``` @@ -83,7 +82,6 @@ const funnel = useFunnel({ steps, initial: { step: 'A', - context: {} } }); diff --git a/docs/src/pages/docs/migration.ko.mdx b/docs/src/pages/docs/migration.ko.mdx index b4f2bc2..1351beb 100644 --- a/docs/src/pages/docs/migration.ko.mdx +++ b/docs/src/pages/docs/migration.ko.mdx @@ -44,7 +44,6 @@ const funnel = useFunnel({ steps, initial: { step: 'A', - context: {} } }); ``` @@ -83,7 +82,6 @@ const funnel = useFunnel({ steps, initial: { step: 'A', - context: {} } }); @@ -178,7 +176,6 @@ const funnel = useFunnel({ steps, initial: { step: intialStep as keyof typeof steps, - context: {} } }); ``` @@ -215,7 +212,6 @@ const funnel = useFunnel({ steps, initial: { step: 'A', - context: {} } }); diff --git a/docs/src/pages/docs/overlay.en.mdx b/docs/src/pages/docs/overlay.en.mdx index 1cfcc1a..5ccb448 100644 --- a/docs/src/pages/docs/overlay.en.mdx +++ b/docs/src/pages/docs/overlay.en.mdx @@ -57,7 +57,6 @@ export const Example = () => { id: "overlay-example", initial: { step: "SchoolInput", - context: {}, }, }); diff --git a/docs/src/pages/docs/overlay.ko.mdx b/docs/src/pages/docs/overlay.ko.mdx index 1223400..8d79523 100644 --- a/docs/src/pages/docs/overlay.ko.mdx +++ b/docs/src/pages/docs/overlay.ko.mdx @@ -57,7 +57,6 @@ export const Example = () => { id: "overlay-example", initial: { step: "학교입력", - context: {}, } }); return ( diff --git a/docs/src/pages/docs/sub-funnel.en.mdx b/docs/src/pages/docs/sub-funnel.en.mdx index e53ec0b..15b5441 100644 --- a/docs/src/pages/docs/sub-funnel.en.mdx +++ b/docs/src/pages/docs/sub-funnel.en.mdx @@ -42,7 +42,6 @@ export function Example() { id: 'main-funnel', initial: { step: 'A', - context: {}, }, }); return ( @@ -88,7 +87,6 @@ export function BFunnel({ a, onNext }: Props) { id: 'b-funnel', initial: { step: 'B1', - context: {}, }, }); return ( diff --git a/docs/src/pages/docs/sub-funnel.ko.mdx b/docs/src/pages/docs/sub-funnel.ko.mdx index b1aa479..61c814f 100644 --- a/docs/src/pages/docs/sub-funnel.ko.mdx +++ b/docs/src/pages/docs/sub-funnel.ko.mdx @@ -42,7 +42,6 @@ export function Example() { id: "main-funnel", initial: { step: "A", - context: {} } }); return ( @@ -88,7 +87,6 @@ export function BFunnel({ a, onNext }: Props) { id: "b-funnel", initial: { step: "B1", - context: {} } }); return ( diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 69d1234..0e9c722 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -44,6 +44,12 @@ export type FunnelStateByContextMap = [key in keyof TStepContextMap & string]: FunnelState; }[keyof TStepContextMap & string]; +export type FunnelInitialStateByContextMap = { + [key in keyof TStepContextMap & string]: Partial extends TStepContextMap[key] + ? { step: key; context?: TStepContextMap[key] } + : { step: key; context: TStepContextMap[key] }; +}[keyof TStepContextMap & string]; + export type FunnelTransition< TStepContextMap extends AnyStepContextMap, TStepKey extends keyof TStepContextMap & string, diff --git a/packages/core/src/stepBuilder.ts b/packages/core/src/stepBuilder.ts index 345ed46..c20393a 100644 --- a/packages/core/src/stepBuilder.ts +++ b/packages/core/src/stepBuilder.ts @@ -87,6 +87,6 @@ class SimpleFunnelStepBuilder< } } -export function createFunnelSteps() { +export function createFunnelSteps() { return new SimpleFunnelStepBuilder(); } diff --git a/packages/core/src/useFunnel.tsx b/packages/core/src/useFunnel.tsx index edf4297..d0f1cd0 100644 --- a/packages/core/src/useFunnel.tsx +++ b/packages/core/src/useFunnel.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo } from 'react'; import { - AnyFunnelState, AnyStepContextMap, FunnelHistory, + FunnelInitialStateByContextMap, FunnelStateByContextMap, FunnelStep, FunnelStepByContextMap, @@ -16,7 +16,7 @@ import { useStateStore, useStateSubscriberStore, useUpdatableRef } from './utils export interface UseFunnelOptions { id: string; - initial: FunnelStateByContextMap; + initial: FunnelInitialStateByContextMap; steps?: { [TStepName in keyof TStepContextMap]: FunnelStepOption; }; @@ -56,14 +56,23 @@ export function createUseFunnel : _TStepContextMap = string extends keyof _TStepContextMap ? Record : _TStepContextMap, >(options: UseFunnelOptions & _TFunnelOption): UseFunnelResults { + const normalizedInitial = useMemo( + () => + ({ + ...options.initial, + context: options.initial.context ?? {}, + }) as FunnelStateByContextMap, + [options.initial], + ); const optionsRef = useUpdatableRef(options); + const normalizedInitialRef = useUpdatableRef(normalizedInitial); const router = useFunnelRouter({ ...optionsRef.current, id: optionsRef.current.id, - initialState: optionsRef.current.initial, + initialState: normalizedInitial, }); const currentState = (router.history[router.currentIndex] ?? - options.initial) as FunnelStateByContextMap; + normalizedInitial) as FunnelStateByContextMap; const currentStateRef = useUpdatableRef(currentState); const cleanUpRef = useUpdatableRef(router.cleanup); @@ -107,7 +116,7 @@ export function createUseFunnel