Skip to content

Navigation Menu

Sign in
Sign up

fix(Stepper): define the public context boundary and deprecate useStepperContext #6074

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Alif416 wants to merge 3 commits into facebook:main
base: main
Choose a base branch
Loading
from Alif416:fix/stepper-context-public-surface
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/stepper-context-public-surface.md
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@astryxdesign/core': patch
---

[component] Deprecate `useStepperContext` and narrow `StepperContextValue` to
the supported subset. Stepper's private coordination — the connector-fill
choreography (`previousActiveStep`) and the dev-mode step registry
(`registerStep`) — is no longer named on the public interface, so changing it
can no longer break consumer types. Both names stay exported until the next
major.

Custom step composition is not, and was not, supported through this hook: a
Stepper builds its context entirely from the props you passed it, so the hook
returns nothing the call site already lacks, and a hand-rolled step still
cannot draw a correct connector track. Compose with `<Step>` and its
`children`, `indicator`, and `endContent` slots, and gate step content on the
same state that drives `activeStep`.

@alif416
4 changes: 2 additions & 2 deletions packages/core/src/Stepper/Step.tsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import type {BaseProps} from '../BaseProps';
import {Icon} from '../Icon';
import {VisuallyHidden} from '../VisuallyHidden';
import {useTranslator} from '../i18n';
import {useStepperContext} from './StepperContext';
import {useStepperCoordination} from './StepperContext';
import {stepMarker} from './stepper.stylex';
import type {StepStatus} from './StepStatus';

Expand Down Expand Up @@ -1017,7 +1017,7 @@ export function Step({
...rest
}: StepProps) {
const t = useTranslator();
const ctx = useStepperContext();
const ctx = useStepperCoordination();
const {
activeStep,
previousActiveStep,
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/Stepper/Stepper.doc.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ export const docs = {
description:
'Use status only to apply a semantic color (accent/success/warning/error); pass a custom icon for richer indicators.',
},
{
guidance: true,
description:
"Drive step content from the same state that drives activeStep. The state you pass to <Stepper activeStep> is the state a step's content should read — gate children on it directly rather than reading the stepper back from a context.",
},
{
guidance: false,
description:
'Call useStepperContext. It is deprecated and will be removed in the next major: a Stepper builds its context from the props you passed it, so the hook returns nothing the call site does not already have. Build custom steps from <Step> and its children, indicator, and endContent slots — a hand-rolled step cannot draw a correct connector track.',
},
{
guidance: false,
description:
Expand Down Expand Up @@ -255,9 +265,15 @@ export const docsDense = {
guidance: true,
description: 'Provide onStepClick for non-linear workflows.',
},
{
guidance: true,
description:
'Gate step children on the same state you pass to activeStep.',
},
{
guidance: false,
description: 'Use for fewer than 3 or more than 7 steps.',
description:
'Use for fewer than 3 or more than 7 steps. Call useStepperContext (deprecated, removed next major — it returns only props you passed; build custom steps from <Step> slots).',
},
],
},
Expand Down Expand Up @@ -321,6 +337,16 @@ export const docsZh = {
"当页面已有返回/继续控件时,将 horizontalOptions.collapsedVariant 设为 'withLabel';当周围界面同时提供当前步骤标题和导航、只需要裸进度轨道时,将其设为 'hiddenLabel'。",
},
{guidance: true, description: '为非线性工作流程提供 onStepClick。'},
{
guidance: true,
description:
'用驱动 activeStep 的同一状态来控制步骤内容,而不是从上下文中回读步骤器状态。',
},
{
guidance: false,
description:
'使用 useStepperContext。它已弃用,将在下一个主版本中移除:步骤器的上下文完全由你传入的 props 构建,该 hook 不会返回调用处尚未拥有的任何内容。请用 <Step> 及其 children、indicator、endContent 插槽来构建自定义步骤。',
},
{guidance: false, description: '少于3个步骤时使用步骤器。'},
{guidance: false, description: '超过7个步骤时使用步骤器。'},
],
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/Stepper/Stepper.public.test.ts
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Stepper.public.test.ts
* @input Imports the Stepper barrel and its context module
* @output Locks the supported public context surface
* @position Compatibility test guarding @astryxdesign/core/Stepper
*
* The decision this file encodes: `useStepperContext` and
* `StepperContextValue` are NOT a supported extension point. Custom step
* composition goes through `<Step>` and its slots. Both names stay exported
* until the next major so the removal lands at an explicit compatibility
* boundary, and until then they must not carry Stepper's private
* coordination — see StepperContext.ts.
*
* The `expectTypeOf` assertions here are enforced by `pnpm -F
* @astryxdesign/core typecheck`, not by the test run — a failing one surfaces
* as `TS2554: Expected 2 arguments, but got 1` on the assertion's line, which
* is how vitest reports a type assertion that did not hold. Verified by
* re-widening `StepperContextValue` and watching exactly the matching lines
* fail.
*/

import {readFile} from 'node:fs/promises';
import {resolve} from 'node:path';

import {describe, expect, expectTypeOf, it} from 'vitest';

// Type-only: the runtime surface is checked through a dynamic `import('./index')`
// below, so nothing here is needed as a value.
import type {useStepperContext, StepperContextValue} from './index';
import type {StepperCoordination} from './StepperContext';

describe('Stepper public context surface', () => {
it('keeps private coordination off the public interface', () => {
// The two fields that made an internal change a consumer type break: the
// connector-fill choreography and the dev-mode step registry. Neither is
// something a consumer configures, so neither is named out here.
expectTypeOf<StepperContextValue>().not.toHaveProperty(
'previousActiveStep',
);
expectTypeOf<StepperContextValue>().not.toHaveProperty('registerStep');

// Guarded at the hook too, which is the declaration a consumer actually
// reads — widening the return type is the way this would regress.
expectTypeOf<ReturnType<typeof useStepperContext>>().not.toHaveProperty(
'previousActiveStep',
);
expectTypeOf<ReturnType<typeof useStepperContext>>().not.toHaveProperty(
'registerStep',
);
});

it('still names the fields the deprecation window promises', () => {
// Narrowing removed private coordination, not the supported reads. Anyone
// already calling the hook keeps compiling until the major.
expectTypeOf<StepperContextValue>().toHaveProperty('activeStep');
expectTypeOf<StepperContextValue>().toHaveProperty('orientation');
expectTypeOf<StepperContextValue>().toHaveProperty('isNonLinear');
expectTypeOf<StepperContextValue>().toHaveProperty('onStepClick');
expectTypeOf<StepperContextValue>().toHaveProperty('density');
expectTypeOf<StepperContextValue>().toHaveProperty('indicatorPosition');

expectTypeOf<
ReturnType<typeof useStepperContext>
>().toEqualTypeOf<StepperContextValue>();
});

it('carries one value on the wire, widened only inside the module', () => {
// The split is about which declaration a consumer can reach, not about
// building a second object per render. If these ever diverge structurally,
// the provider is handing Step something the public read cannot describe.
expectTypeOf<StepperCoordination>().toMatchTypeOf<StepperContextValue>();
expectTypeOf<StepperCoordination>().toHaveProperty('previousActiveStep');
expectTypeOf<StepperCoordination>().toHaveProperty('registerStep');
});

it('closes the internal seam by module boundary, not by naming', async () => {
// An `@internal` tag is a note to a reader; a builder reading an exported
// declaration finds every name on it and can reasonably wire one. So the
// coordination hook and its type must not reach the barrel at all.
const entry = await import('./index');
expect(Object.keys(entry)).not.toContain('useStepperCoordination');
expect(Object.keys(entry)).not.toContain('StepperContext');

// Checked against the barrel's CODE, with comments stripped: the block
// documenting the deprecation names `StepperCoordination` in prose, and a
// raw substring check cannot tell that from an export.
const source = await readFile(resolve(__dirname, 'index.ts'), 'utf8');
const code = source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '');
expect(code).not.toContain('StepperCoordination');
expect(code).not.toContain('useStepperCoordination');
});

it('still exports the deprecated names, so removal needs a major', async () => {
// The other half of the contract: this test fails if someone drops them in
// a patch instead of at the compatibility boundary.
const entry = await import('./index');
expect(Object.keys(entry)).toContain('useStepperContext');

const source = await readFile(
resolve(__dirname, 'StepperContext.ts'),
'utf8',
);
expect(source).toContain('@deprecated');
});
});
9 changes: 7 additions & 2 deletions packages/core/src/Stepper/Stepper.tsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
* styling to the frame that contains both the list and compact summary, while
* the semantic `<ol>` remains the measured ref and DOM pass-through target.
*
* The value it publishes is `StepperCoordination`, which is wider than the
* deprecated public `StepperContextValue` — see StepperContext.ts for why the
* two are split.
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/core/src/Stepper/Stepper.doc.mjs (props table, features, implementation notes)
* - /packages/core/src/Stepper/Stepper.test.tsx (tests for new/changed behavior)
Expand Down Expand Up @@ -46,7 +50,7 @@ import {
StepperContext,
type StepperOrientation,
type StepperIndicatorPosition,
type StepperContextValue,
type StepperCoordination,
} from './StepperContext';

/**
Expand Down Expand Up @@ -407,7 +411,8 @@ export function Stepper({

const [summarySlot, setSummarySlot] = useState<HTMLElement | null>(null);

const ctxValue = useMemo<StepperContextValue>(
const ctxValue = useMemo<StepperCoordination>(

() => ({
activeStep,
previousActiveStep,
Expand Down
106 changes: 93 additions & 13 deletions packages/core/src/Stepper/StepperContext.ts
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@
/**
* @file StepperContext.ts
* @input Uses React createContext/use
* @output Exports StepperContext, useStepperContext, and context types
* @output Exports StepperContext, the internal coordination hook, and the
* deprecated public hook/type
* @position Context for Stepper <-> Step communication
*
* TWO SHAPES, NOT ONE.
* `StepperCoordination` is what the provider actually carries: everything Step
* needs from Stepper, including the connector-fill choreography and the
* dev-mode index registry. It is deliberately NOT re-exported from `index.ts`,
* so the seam is closed by module boundary rather than by naming convention —
* the same reason `BusyIndicatorLane` lives outside Typeahead's barrel. An
* `@internal` tag is a note to a reader, not a boundary: a builder reading an
* exported declaration finds every name on it and can reasonably wire one,
* pinning a Stepper-internal detail as permanent API.
*
* `StepperContextValue` is the deprecated public subset. It stays exported for
* one compatibility window (see index.ts) and names only fields that were
* already safe to read. Narrowing it is what stops an internal change to the
* fill choreography from landing as a consumer type break.
*
* SYNC: When modified, update these files to stay in sync:
* - /packages/core/src/Stepper/Stepper.doc.mjs
* - /packages/core/src/Stepper/index.ts
* - /packages/core/src/Stepper/Stepper.public.test.ts
*/

import {createContext, use, type RefCallback} from 'react';
Expand All @@ -28,8 +45,36 @@ export type StepperDensity = 'compact' | 'balanced' | 'spacious';
*/
export type StepperIndicatorPosition = 'separated' | 'on-track';

/**
* The deprecated public read of a Stepper's context.
*
* Every field here is a value the caller already passed to `<Stepper>`, so
* reading them back buys nothing a prop or the caller's own state does not
* already give — which is why the hook is on its way out rather than being
* grown into a composition API.
*
* @deprecated Not a supported extension point, and scheduled for removal in
* the next major. Thread `activeStep` and the layout props from the state you
* already own; see `useStepperContext`.
*/
export interface StepperContextValue {
activeStep: number;
orientation: StepperOrientation;
isNonLinear: boolean;
onStepClick: ((index: number) => void) | null;
density: StepperDensity;
indicatorPosition: StepperIndicatorPosition;
}

/**
* What the provider actually carries. Package-internal: exported from this
* module for Stepper and Step, never from `index.ts`.
*
* Extends the public subset so there is exactly one object on the wire — the
* split is a matter of which declaration a consumer can reach, not of building
* a second value per render.
*/
export interface StepperCoordination extends StepperContextValue {
/**
* The `activeStep` this stepper last rendered with, so a Step can tell
* whether the change it is reacting to was a single step forward — the one
Expand All @@ -38,16 +83,10 @@ export interface StepperContextValue {
* on the first render, which is what keeps a stepper that mounts mid-flow
* from animating its way to the step it opened on.
*
* Internal: not part of the public API, and deliberately not a Stepper prop.
* When the connector animates is behaviour the stepper owns, not something a
* consumer configures.
* Deliberately not a Stepper prop either. When the connector animates is
* behaviour the stepper owns, not something a consumer configures.
*/
previousActiveStep: number;
orientation: StepperOrientation;
isNonLinear: boolean;
onStepClick: ((index: number) => void) | null;
density: StepperDensity;
indicatorPosition: StepperIndicatorPosition;
/**
* Dev-mode index registration and compact-navigation metadata. Each Step
* calls this on mount with its `step` index and disabled state. The Stepper
Expand Down Expand Up @@ -102,16 +141,57 @@ export interface StepperContextValue {
minStepWidthMeasureRef: RefCallback<HTMLDivElement>;
}

export const StepperContext = createContext<StepperContextValue | null>(null);
export const StepperContext = createContext<StepperCoordination | null>(null);
StepperContext.displayName = 'StepperContext';

export function useStepperContext(): StepperContextValue {
function useCoordination(hookName: string): StepperCoordination {
const ctx = use(StepperContext);
if (ctx == null) {
throw new Error(
'useStepperContext must be used within Stepper. ' +
'Wrap your Step in <Stepper>.',
`${hookName} must be used within Stepper. Wrap your Step in <Stepper>.`,
);
}
return ctx;
}

/**
* Stepper <-> Step coordination. Package-internal; see the header.
*/
export function useStepperCoordination(): StepperCoordination {
return useCoordination('useStepperCoordination');
}

/**
* Reads the enclosing Stepper's configuration.
*
* There is no supported use case for this: a Stepper's context is built
* entirely from props the caller passed in, so anything it returns is already
* in hand at the call site. Custom step composition is not supported through
* it either — the connector fill is choreographed inside `Step`, so a
* hand-rolled step reading this context still cannot draw a correct track.
* Compose with `<Step>` and its `children`, `indicator`, and `endContent`
* slots instead.
*
* The returned object is the live coordination value, narrowed on the way out.
* Narrowing the *declaration* is the point — that is the surface a consumer
* reads and builds against — so it is done in the type rather than by copying
* fields into a fresh object on every render of every step.
*
* @deprecated Not a supported extension point, and scheduled for removal in
* the next major. Thread `activeStep` and the layout props from the state you
* already own — the state driving `<Stepper activeStep>` is the same state.
*
* @example
* ```
* // Instead of reading the context from inside a step:
* const [activeStep, setActiveStep] = useState(0);
* <Stepper activeStep={activeStep} onStepClick={setActiveStep}>
* <Step step={0} label="Details">
* {activeStep === 0 && <DetailsForm />}
* </Step>
* </Stepper>
* ```
*/
export function useStepperContext(): StepperContextValue {
return useCoordination('useStepperContext');
}
Loading
Loading

AltStyle によって変換されたページ (->オリジナル) /