Flow & pages
Every flow export of @appfunnel-dev/sdk, generated from the SDK source. Signatures and
descriptions are exact; for how these fit together, see the guides.
useAsset
hook · flow/mount.tsx
useAsset(name: string): string | undefineduseAsset('logo.svg') — one asset’s CDN URL, or undefined if not injected.
useAssets
hook · flow/mount.tsx
useAssets(): Record<string, string>The whole asset map (logical name → CDN URL). {} outside a provider.
useExperiment
hook · flow/flow.tsx
useExperiment(id: string): string | undefinedconst variant = useExperiment('welcome-headline') — the variant key this
visitor is assigned in an experiment (undefined if not in one). Page-variant
swapping is automatic; this is for branching within a page or custom logging.
useGroupProgress
hook · flow/flow.tsx
useGroupProgress(): GroupProgressuseGroupProgress() — the visitor-routed, branch-aware grouped-progress structure
for a grouped <ProgressBar>: ordered PageGroup segments (from meta.group),
the activeIndex of the current group (-1 when the current page is ungrouped), and
the active group’s within-group activeFraction (0–1). Counts only the pages THIS
visitor will actually visit given their answers (see groupProgress). A funnel
with no group keys yields { groups: [], activeIndex: -1, activeFraction: 0 }.
Must be used inside <FunnelView>.
Each segment’s key is the slug identifier from PageMeta.group, NOT a
display string — resolve it to a label in your funnel code, e.g.
const GROUP_LABELS = { profile: 'My Profile' }; GROUP_LABELS[group.key] or
t(groups.${group.key}). This keeps grouping stable across copy and locale changes.
useNavigation
hook · flow/flow.tsx
useNavigation(): NavigationStateconst { next, back, go, page } = useNavigation().
usePage
hook · flow/flow.tsx
usePage(): PageContextusePage() — the current page’s read-only context, reactive to navigation.
AssetsProvider
component · flow/mount.tsx
AssetsProvider({ assets, children, }: { assets?: Record<string, string>; children: ReactNode; }): ReactNodeProvides the funnel’s asset map (mounted for you by createFunnelTree).
Back
component · flow/components.tsx
Back({ children, onClick, ...rest }: BackProps): ReactNodeA button that returns to the previous page; hidden when there’s no history.
Choice
component · flow/components.tsx
Choice({ bind, options, className, selectedClassName, advanceOnSelect, }: ChoiceProps): ReactNodeSingle-select bound to a writable field. <Choice bind="responses.goal" options={["lose", "gain"]} /> — selecting persists + (later) tracks; pass
advanceOnSelect to move to the next page on tap.
FunnelView
component · flow/flow.tsx
FunnelView({ pages, initialKey, trustInitialKey, layout, fallback, prefetch: prefetchMode, onNavigate, }: { pages: RuntimePage[]; initialKey?: string; trustInitialKey?: boolean; layout?: ComponentType<{ children: ReactNode; }>; fallback?: ReactNode; prefetch?: "auto" | "off"; onNavigate?: (key: string, slug: string, isStart: boolean) => void; }): ReactNodeRenders the funnel: shows the current page and drives navigation. next()
resolves the current page’s next predicate against the live snapshot
(writable namespaces + read-only context), falling back to the linear next
page in file order (see nextPage).
Auto-emits funnel.start on mount and page.view on each page change, and
keeps context.page in sync so predicates and usePage() see the live page.
Pass a layout (a { children } component) for persistent chrome — a
header, progress bar, background — rendered inside the nav context but
outside the page swap, so it mounts once and never remounts on navigation
(and can read usePage/useNavigation).
Must live inside <FunnelProvider>.
Next
component · flow/components.tsx
Next({ children, onClick, ...rest }: NextProps): ReactNodeA button that advances the funnel (the current page’s next, else linear).
assignVariant
function · flow/experiments.ts
assignVariant(experimentId: string, seed: string, weights: Record<string, number>): stringDeterministically assign a visitor (seed) to a variant of one experiment.
The hash is namespaced by experimentId so two experiments on the same
traffic don’t correlate (orthogonal layering).
bucketingSeed
function · flow/experiments.ts
bucketingSeed(context: FunnelContext): string | nullThe stable bucketing seed: the signed visitorId, else the known customerId.
Never the session id or fingerprint (landmine #4). null when neither is known
— the caller then serves control instead of bucketing on nothing.
visitorId-FIRST (not customerId-first) is deliberate: the customerId injected for an
anonymous visitor is minted server-side AFTER the first tracked event, so it is absent on
visit 1 and present on visit 2 — seeding on it would flip a returning visitor’s arm and
count them in two arms of the same version. The signed af_vid cookie is the durable
identity that’s stable from the first pageload, so it seeds; customerId is only the fallback
for cookieless SDK embeds. This matches the server-side FUNNEL split, which already seeds on
the verified visitorId (resolve.ts). (Cross-device stable bucketing for IDENTIFIED customers
is P3’s IdentitySDK job, not this anonymous path.)
createFunnelTree
function · flow/mount.tsx
createFunnelTree({ config, pages, layout, checkoutDriver, messages, }: CreateFunnelTreeInput): (opts: MountOpts) => ReactNodeBuild a funnel’s tree(opts) — the standard provider stack over your pages.
export const pages: RuntimePage[] = [ ... ]
export const tree = createFunnelTree({ config, pages, layout: Layout })defineFunnel
function · flow/spine.ts
defineFunnel(def: FunnelDefinition): FunnelDefinitionIdentity helper for a funnel definition.
definePage
function · flow/spine.ts
definePage<P extends object = Record<string, never>>(component: ComponentType<P>): PageComponent<P>Identity helper for a page component. The page is plain React that reaches the
runtime through hooks (useResponse, useNavigation, …) — there is no sdk
prop. export default definePage(function Welcome() { … }).
Image preloading is AUTOMATIC and requires no per-page declaration: the SDK
pre-mounts the likely-next pages HIDDEN (React <Activity mode="hidden">) so the
browser fetches their above-the-fold imagery before the visitor arrives — no
pop-in — while their effects stay deferred until the page is actually shown. See
FunnelView.
entryGuard
function · flow/spine.ts
entryGuard(meta?: PageMeta): Gate | undefinedThe effective entry guard for a page: an explicit meta.guard if the author set
one, otherwise the page-type default (e.g. paywall/upsell need email). The
explicit guard fully overrides the default — it’s not combined. Returns
undefined when neither applies.
evaluateCondition
function · flow/spine.ts
evaluateCondition(cond: Condition, s: FunnelSnapshot): booleanEvaluate a declarative Condition against a snapshot.
evaluateGate
function · flow/spine.ts
evaluateGate(gate: Gate, s: FunnelSnapshot): booleanEvaluate either gate kind to a boolean.
expectedPathLength
function · flow/spine.ts
expectedPathLength(pages: FlowPage[], startKey: string, s: FunnelSnapshot): numberThe number of pages on the path from startKey to a finish (or the end of
the flow) given the current state — the denominator for progress. It walks
the actual routing (nextPage) rather than counting all files, so a
branched funnel reaches 100% on whichever path the visitor’s answers select.
Re-traced per navigation; a visited set guards against routing cycles.
fnv1a
function · flow/experiments.ts
fnv1a(input: string): numberFNV-1a 32-bit hash → unsigned int. Deterministic and isomorphic (no crypto,
runs the same in the browser, a worker, and a build). The same math v0 used,
kept because it’s sound — only the seed changes (identity, not session).
groupProgress
function · flow/spine.ts
groupProgress(pages: FlowPage[], startKey: string, currentKey: string, s: FunnelSnapshot): GroupProgressBuild the grouped-progress structure for the CURRENT visitor by walking the actual
ROUTED path from startKey (mirrors expectedPathLength: first-match routes
against the live snapshot, a visited-guard against cycles, stopping at a finish)
— NOT the static file order. A visitor on the muscle branch therefore gets the
muscle-variant pages counted in their groups, not the lose-variant ones.
Rules (documented decisions):
- Distinct by key, first-appearance order. Groups key on the slug; a key is listed once, positioned by where it first appears. A later re-appearance of the same key folds back into that one group (its pages add to the same count) — it does NOT open a second segment.
- Ungrouped pages don’t break contiguity. A page with no
groupinterleaved inside a group’s span belongs to no segment (counted in nothing), but neither does it split the surrounding group — because grouping is by key, not by runs. - Completion by position.
completedCountcounts a group’s pages strictly before the current page’s index in the routed path; the current page is the in-progress step (credited viaactiveFraction, notcompletedCount). - Current page ungrouped ⇒
activeIndex = -1,activeFraction = 0(other groups still report their completion relative to the cursor). - Loops / back-navigation. Position is the current page’s…
hashToUnit
function · flow/experiments.ts
hashToUnit(seed: string): numberMap a seed string to a stable float in [0, 1).
isVariantKey
function · flow/experiments.ts
isVariantKey(key: string): booleanTrue if a page key is an off-flow variant (welcome@b), by the source-side @ marker.
nextPage
function · flow/spine.ts
nextPage(pages: FlowPage[], currentKey: string, s: FunnelSnapshot): string | undefinedDecide the next page from currentKey:
- the current page’s
nextroutes (resolveRoute), if one matches, else - the next page in file order (the default linear flow), else
undefined(end of funnel / current key not found).
outgoingKeys
function · flow/spine.ts
outgoingKeys(pages: FlowPage[], currentKey: string): string[]Every page a visitor could go to next from currentKey — all route targets
(regardless of gate, since we don’t know which will pass) plus the linear next.
Used to prefetch the likely-next page chunks; not for navigation.
pageMeta
function · flow/spine.ts
pageMeta(meta: PageMeta): PageMetaIdentity helper for a page’s co-located metadata.
export const meta = pageMeta({ type: 'paywall', next: s => … }).
parseSlotKey
function · flow/experiments.ts
parseSlotKey(key: string): { slot: string; variant?: string; }Split a page key into its slot + optional variant: welcome@b → { slot, variant: 'b' }.
pickByWeight
function · flow/experiments.ts
pickByWeight(weights: Record<string, number>, u: number): stringPick a variant key by weight from a stable unit position u ∈ [0,1). Variants
are walked in declaration order and the first whose cumulative weight band
contains u wins, so the same u always lands in the same variant.
resolveExperiments
function · flow/experiments.ts
resolveExperiments(pages: { key: string; }[], experiments: RuntimeExperiment[], seed: string | null): ExperimentResolutionResolve which page version each experiment slot shows for this visitor, and which page keys remain in the linear flow.
Variant pages are detected structurally by the @ in their key, so they collapse
out of the linear flow even for a slot with no active experiment. Each served
experiment is RUNNING with all its arm pages guaranteed present in the LIVE build
(the server draft-write + serve guards enforce the experiment-owned lifecycle
invariant), so the visitor is bucketed deterministically on seed and the assigned
arm’s page is swapped in with no presence check. With no seed there is nothing
stable to bucket on, so every slot renders its own control page.
resolveRoute
function · flow/spine.ts
resolveRoute(routes: Route[] | undefined, s: FunnelSnapshot): string | undefinedResolve an ordered list of routes to a target page key, or undefined to fall
through to the linear next. First route whose gate passes (or has no when)
wins.
BackProps
interface · flow/components.tsx
export interface BackProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode
}ChoiceOption
interface · flow/components.tsx
export interface ChoiceOption {
value: string
label?: ReactNode
}The two-tier component model (doc 07 §“magic ≠ black box”): the magic lives in
headless hooks (useField, useNavigation); these styleable components are
thin, restyle-able wrappers over them. Every <Choice> reduces to useField
- your own JSX — eject any time. They pass
className/DOM props straight through and never dictate a pixel beyond the minimum.
ChoiceProps
interface · flow/components.tsx
export interface ChoiceProps {
bind: string
options: (string | ChoiceOption)[]
className?: string
selectedClassName?: string
advanceOnSelect?: boolean
}Condition
interface · flow/spine.ts
export interface Condition {
field: string
op: ConditionOp
value?: VariableValue | VariableValue[]
}A declarative gate over one namespaced field — UI-editable data the
dashboard renders as a condition row (field op value). field is a
dotted path into the snapshot: responses.goal, user.country,
context.device.isMobile.
CreateFunnelTreeInput
interface · flow/mount.tsx
export interface CreateFunnelTreeInput {
config: FunnelDefinition
pages: RuntimePage[]
layout?: ComponentType<{ children: ReactNode }>
checkoutDriver?: (ctx: CheckoutDriverContext) => CheckoutDriver
messages?: MessageCatalog
}ExperimentArm
interface · flow/experiments.ts
export interface ExperimentArm {
page: string
weight: number
}One arm of a running experiment: which page renders, at what weight.
ExperimentResolution
interface · flow/experiments.ts
export interface ExperimentResolution {
assignments: Record<string, string>
activeKeys: string[]
render: Record<string, string>
slotOf: Record<string, string>
experimentOf: Record<string, string>
versionOf: Record<string, number>
}FlowPage
interface · flow/spine.ts
export interface FlowPage {
key: string
meta?: PageMeta
}A page in the flow: its key + (optional) co-located meta.
FunnelDefinition
interface · flow/spine.ts
export interface FunnelDefinition {
id: string
pages?: FunnelPage[]
checkout?: 'stripe' | 'paddle'
responses?: Record<string, VariableConfig>
data?: Record<string, VariableConfig>
offerings?: string[] | Record<string, string | null>
sampleOfferings?: Record<string, SampleOffering>
locales?: FunnelLocales
locationAwareCurrency?: boolean
}The funnel-level spine. Flow is mostly inferred from page file order + each
page’s next; this holds the funnel-wide config. Stays thin — routing
lives co-located on pages (doc 06: “funnel.ts stays thin”).
FunnelLocales
interface · flow/spine.ts
export interface FunnelLocales {
default: string
supported: string[]
fallback?: string
autoDetectLanguage?: boolean
}FunnelSnapshot
interface · flow/spine.ts
export interface FunnelSnapshot {
user: Record<string, VariableValue>
responses: Record<string, VariableValue>
data: Record<string, VariableValue>
context: FunnelContext
}Read-only view of every namespace a routing/condition predicate can branch
on. user/responses/data are the writable namespaces; context is the
read-only computed one (FunnelContext).
GroupProgress
interface · flow/spine.ts
export interface GroupProgress {
groups: PageGroup[]
activeIndex: number
activeFraction: number
}The visitor-routed, branch-aware structure a grouped <ProgressBar> renders. See
groupProgress. groups are ordered by first appearance along the routed
path; activeIndex locates the current group (or -1 when the current page is
ungrouped); activeFraction is the active group’s within-group fill (0–1).
MountOpts
interface · flow/mount.tsx
export interface MountOpts {
offerings?: OfferingInput[]
locale?: string
mode?: 'test' | 'live'
initialKey?: string
basePath?: string
assets?: Record<string, string>
messages?: MessageCatalog
visitorId?: string | null
customerId?: string | null
tracking?: {
apiBase: string
campaignId?: string | null
preview?: {
buildId: string
token: string
} | null
funnelId: string
experiment?: { id: string; variant: string; version?: number }
route?: { id: string }
}
previewCheckout?: {
apiBase: string
funnelId: string
buildId: string
token: string
}
sessionValues?: Record<string, string | number | boolean | null | string[]>
experiments?: RuntimeExperiment[]
sessionId?: string | null
embed?: boolean
deferSessionValues?: boolean
trustInitialKey?: boolean
}What the renderer injects when mounting a funnel build (SSR and hydration).
NavigationState
interface · flow/flow.tsx
export interface NavigationState {
page: PageContext
next: () => void
back: () => void
go: (key: string) => void
prefetch: (key: string) => void
nextCandidates: string[]
canGoBack: boolean
isNavigating: boolean
}NextProps
interface · flow/components.tsx
export interface NextProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children?: ReactNode
}PageGroup
interface · flow/spine.ts
export interface PageGroup {
key: string
pageCount: number
completedCount: number
active: boolean
}One segment of a grouped progress bar (a distinct PageMeta.group).
PageMeta
interface · flow/spine.ts
export interface PageMeta {
type?: PageType
slug?: string
group?: string
next?: Route[]
guard?: Gate
dynamic?: boolean
prerender?: boolean
}Route
interface · flow/spine.ts
export interface Route {
to: string
when?: Gate
}One routing edge out of a page. to is a static target key (the dashboard
reads these to draw the flow graph); when gates the edge (omit = fallback).
RuntimeExperiment
interface · flow/experiments.ts
export interface RuntimeExperiment {
id: string
slot: string
variants: Record<string, ExperimentArm>
metric?: string
version?: number
}A runtime experiment record — the operational wiring the platform owns and
hands to the SDK (not authored in the funnel source). Projected from the unified
Experiment/ExperimentArm graph (a RUNNING PAGE experiment + its arms): the
experiment’s slot names the base page, and each arm becomes a variants entry
{ page: arm.pageKey, weight: arm.weight } keyed by arm.label. The variant
pages are code in the LIVE build; this is the live split over them.
The serve join only ever hands over RUNNING experiments, and the server’s draft-write + serve guards make every active arm’s page GUARANTEED present in the LIVE build (an experiment owns its arm pages’ lifecycle — you end the experiment to remove a page, you can’t drift it away). So there is no lifecycle/winner state and no “arm page missing” case to resolve here.
RuntimePage
interface · flow/flow.tsx
export interface RuntimePage {
key: string
meta?: PageMeta
Component?: ComponentType
load?: () => Promise<PageModule>
}A page wired into the runtime. Provide either an eager Component (small
funnels, tests) or a lazy load (code-split: the renderer hands a dynamic
import() so a big funnel ships one chunk per page, not the whole funnel).
ConditionOp
type · flow/spine.ts
export type ConditionOp =
| 'eq'
| 'neq'
| 'in'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'exists'
| 'empty'
| 'contains'Gate
type · flow/spine.ts
export type Gate = Condition | PredicateAn edge gate: either declarative data (Condition) or a Predicate.
MountModule
type · flow/mount.tsx
export type MountModule = {
pages: RuntimePage[]
tree: (opts: MountOpts) => ReactNode
}The shape a funnel build’s mount.tsx module must export — the named,
versioned contract between a build and the renderer/editor:
import type { MountModule } from '@appfunnel-dev/sdk'.
PageComponent
type · flow/spine.ts
export type PageComponent<P extends object = Record<string, never>> = ComponentType<P>A page component wired into the runtime — plain React that reaches the runtime through hooks. Kept as a named alias so the public type export stays stable.
PageModule
type · flow/flow.tsx
export type PageModule = ComponentType | { default: ComponentType }What a page’s load resolves to — a component, or a module default-exporting one.
PageType
type · flow/spine.ts
export type PageType =
| 'default' // plain content/step page (the baseline)
| 'email-capture' // collects user.email — the mandatory identity step (validated)
| 'paywall' // the offer/pricing page → drives paywall-view + paywall-CR
| 'upsell' // post-purchase one-click → drives Upsell Rate + off-session charge
| 'finish'Predicate
type · flow/spine.ts
export type Predicate = (s: FunnelSnapshot) => booleanA code-predicate gate — s => s.responses.goal === 'gain'.
AssetsContext
const · flow/mount.tsx
AssetsContext(props: import("/vercel/path0/node_modules/@types/react/index").ProviderProps<Record<string, string>>): ReactNodeFunnel asset map: logical name (e.g. logo.svg) → content-addressed CDN URL,
injected by the renderer. Assets live in a SHARED content-addressed store
(appfunnel/assets/<hash>), so identical content is stored once across all
builds and funnels while staying immutable. Both SSR and client read the same
injected map → URLs match. Defaults to {} (no provider needed in tests).