diff --git a/.agents/skills/handle-pr-comments/SKILL.md b/.agents/skills/handle-pr-comments/SKILL.md new file mode 100644 index 000000000000..9df27fa7e6d0 --- /dev/null +++ b/.agents/skills/handle-pr-comments/SKILL.md @@ -0,0 +1,22 @@ +--- +name: handle-pr-comments +description: Triage and resolve GitHub PR review comments one by one, interactively. Use when the user asks to handle, address, respond to, or resolve PR review comments, or mentions reviewer feedback on a pull request. +--- + +# Handle PR Comments + +Evaluate review comments on the PR (from the current branch, or a PR number/URL if given). For each unresolved comment: summarize the feedback, suggest a resolution, ask the user how to resolve it, apply their choice, commit if needed, and resolve the thread. + +1. **Find the PR.** Use the given number/URL, else derive it from the current branch (`gh pr view`). Stop if no PR exists. + +2. **Fetch unresolved review threads** via the GraphQL `reviewThreads` field on the pull request — request each thread's `id`, `isResolved`, `isOutdated`, `path`, `line`, and comments. Filter to `isResolved == false`. (Use GraphQL, not REST: only it exposes thread `id`s and resolution state.) + +3. **Loop one comment at a time.** For each: (a) summarize the feedback + file/line, reading surrounding code; (b) suggest a concrete fix; (c) ask via AskQuestion with options — apply suggested fix / apply a different fix / reply only / skip; (d) apply the choice; (e) if applicable, summarize what was changed and ask whether to commit; (f) resolve the thread via the GraphQL `resolveReviewThread` mutation. + +4. **Report** an overview of what was changed, committed, replied to, skipped, and resolved. Include links. + +## Notes + +- Handle real-user comments before addressing AI agents' (Copilot, CodeRabbit). +- Group duplicate comments; when the same/similar issue is mentioned multiple times, handle them all at once. +- Make a separate commit for each change. \ No newline at end of file diff --git a/.agents/skills/open-pr/SKILL.md b/.agents/skills/open-pr/SKILL.md new file mode 100644 index 000000000000..8cf1abafbc70 --- /dev/null +++ b/.agents/skills/open-pr/SKILL.md @@ -0,0 +1,77 @@ +--- +name: open-pr +description: Opens a pull request from the current branch using the PR template. Use when the user asks to open a PR, create a pull request, or invokes /open-pr. +allowed-tools: Bash, Read, AskQuestion +--- + +# Open Pull Request + +Opens a draft PR from the current branch following Storybook conventions. + +## Workflow + +### 1. Gather context + +Run in parallel: `git status`, `git diff`, `git log --oneline ...HEAD` (after step 2), `git branch -vv`. + +Push first if needed: `git push -u origin HEAD`. + +### 2. Detect base branch + +```bash +git fetch origin +bash .agents/skills/open-pr/scripts/detect-base-branch.sh +``` + +Detects base in order: tracked upstream → reflog checkout source → closest `origin/*` ancestor (fewest commits from branch tip to HEAD). Supports telescoped/stacked PRs off feature branches, not only `next`/`main`. Skips branches at the same commit as HEAD. Tie-break: feature branch over trunk, then `next` over `main`. Falls back to `next`. Tell the user the result. + +### 3. Ask for labels + +Use **AskQuestion** for three questions. Options from `.github/PULL_REQUEST_TEMPLATE.md`: + +| Question | Options | +| -------- | ------- | +| CI label | `ci:normal`, `ci:merged`, `ci:daily` | +| QA label | `qa:needed`, `qa:skip` | +| Type label | `bug`, `maintenance`, `dependencies`, `build`, `cleanup`, `documentation`, `feature request`, `BREAKING CHANGE`, `other` | + +Verify the available labels with the PR template. + +### 4. Draft title and body + +**Title:** `[Area]: [Description]` — see the `pr` skill for format and examples. + +**Body:** Read `.github/PULL_REQUEST_TEMPLATE.md`. Copy it **exactly** (keep all HTML comments). Fill in: + +- `Closes #` when an issue is linked +- **What I did** +- Testing checkboxes and mandatory manual-testing steps (or state why none is needed) +- Documentation checkboxes when applicable +- Leave maintainer checklist items unchecked + +### 5. Create the PR + +```bash +gh pr create \ + --draft \ + --base "" \ + --title ": " \ + --body "$(cat <<'EOF' + +EOF +)" \ + --assignee @me \ + --label ",," +``` + +### 6. Report and offer canary + +Share the PR URL. Then **AskQuestion**: "Do you want to create a canary release for this PR?" + +- **Yes** — run `/canary ` and report workflow status +- **No** — stop + +## Notes + +- Always draft; always assign `@me`. +- For canary details after publishing, see the `canary` skill. diff --git a/.agents/skills/open-pr/scripts/detect-base-branch.sh b/.agents/skills/open-pr/scripts/detect-base-branch.sh new file mode 100755 index 000000000000..b77a92c982f9 --- /dev/null +++ b/.agents/skills/open-pr/scripts/detect-base-branch.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Pick the remote branch the current branch most likely forked from. +# Supports telescoped/stacked PRs by scanning all origin/* ancestors. +set -euo pipefail + +current_branch=$(git branch --show-current) +head_tip=$(git rev-parse HEAD) +best="" +min_commits=999999 +best_is_feature=0 + +is_trunk() { + case "$1" in + main | next) return 0 ;; + *) return 1 ;; + esac +} + +normalize_branch_name() { + local name=$1 + name=${name#origin/} + name=${name#remotes/origin/} + echo "${name}" +} + +branch_exists_on_origin() { + git rev-parse --verify "origin/$1" >/dev/null 2>&1 +} + +is_valid_base() { + local branch=$1 + local ref="origin/${branch}" + + [ "${branch}" != "${current_branch}" ] || return 1 + branch_exists_on_origin "${branch}" || return 1 + + local branch_tip + branch_tip=$(git rev-parse "${ref}") + [ "${branch_tip}" != "${head_tip}" ] || return 1 + git merge-base --is-ancestor "${ref}" HEAD 2>/dev/null +} + +commit_count_since() { + git rev-list --count "origin/$1..HEAD" +} + +consider() { + local branch=$1 + local commit_count=$2 + + if ! is_valid_base "${branch}"; then + return + fi + + local is_feature=0 + is_trunk "${branch}" || is_feature=1 + + if [ -z "${best}" ]; then + best=${branch} + min_commits=${commit_count} + best_is_feature=${is_feature} + return + fi + + if [ "${commit_count}" -lt "${min_commits}" ]; then + best=${branch} + min_commits=${commit_count} + best_is_feature=${is_feature} + return + fi + + if [ "${commit_count}" -gt "${min_commits}" ]; then + return + fi + + # Tie: prefer telescoped parent over trunk, then next over main. + if [ "${best_is_feature}" -eq 0 ] && [ "${is_feature}" -eq 1 ]; then + best=${branch} + best_is_feature=${is_feature} + return + fi + + if [ "${best_is_feature}" -eq 1 ] && [ "${is_feature}" -eq 0 ]; then + return + fi + + if [ "${branch}" = "next" ]; then + best=${branch} + best_is_feature=${is_feature} + fi +} + +try_explicit_base() { + local branch=$1 + branch=$(normalize_branch_name "${branch}") + consider "${branch}" "$(commit_count_since "${branch}")" +} + +# 1. Tracked upstream (set when branching with -u or --track) +if upstream=$(git rev-parse --abbrev-ref '@{upstream}' 2>/dev/null); then + try_explicit_base "${upstream}" +fi + +# 2. Reflog: most recent branch we checked out from +if [ -z "${best}" ]; then + while IFS= read -r line; do + if [[ ${line} =~ checkout:\ moving\ from\ (.+)\ to\ ${current_branch} ]]; then + try_explicit_base "${BASH_REMATCH[1]}" + break + fi + if [[ ${line} =~ branch:\ Created\ from\ (.+) ]]; then + try_explicit_base "${BASH_REMATCH[1]}" + break + fi + done < <(git reflog show --format='%gs' "${current_branch}" 2>/dev/null || true) +fi + +# 3. Scan all origin/* branches — closest strict ancestor wins +while IFS= read -r ref; do + branch=${ref#origin/} + + if [ "${branch}" = "HEAD" ] || [ "${branch}" = "${current_branch}" ]; then + continue + fi + + if ! is_valid_base "${branch}"; then + continue + fi + + consider "${branch}" "$(commit_count_since "${branch}")" +done < <(git for-each-ref --format='%(refname:short)' refs/remotes/origin/) + +if [ -z "${best}" ]; then + best="next" +fi + +echo "${best}" diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index ba97bec26cd9..b5795b21b9d2 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -41,16 +41,54 @@ Add these labels to the PR: - `ci:daily` - daily sandbox set; use this when changes affect prerelease sandboxes or sandboxes pinned to a framework or React version other than latest - `ci:docs` - documentation-only changes (use with `documentation` category) +**QA (required, pick one):** + +Tells the release team whether manual QA is needed before the next minor release. + +- `qa:needed` — a human must manually verify this PR at release time +- `qa:skip` — no per-PR manual QA needed at release time + +Heuristics: + +- User explicitly asked for manual QA before release → `qa:needed` +- Part of a larger project that gets holistic QA (not per-PR) → `qa:skip` +- Touches paths, filesystem, or anything that could break on Windows → `qa:needed` +- Complex change spanning multiple areas that must work together → `qa:needed` +- Small change in central/shared code with high side-effect risk (e.g. layout CSS in shared UI) → `qa:needed` +- Simple, straightforward change → `qa:skip` +- Unsure → ask the user whether manual QA before minor release is necessary + ## PR body Read `.github/PULL_REQUEST_TEMPLATE.md` from the repository root. Copy that template **EXACTLY**, including all HTML comments (``). Fill in the relevant sections based on the changes, but keep all comments intact. +### Manual testing (required) + +The **Manual testing** section is mandatory — never leave it empty. Write steps for a separate maintainer, not a log of how you tested. + +Each step should be: + +- Clear and easy to follow +- Copy-pasteable shell commands where applicable +- Explicit about what behavior to inspect (expected outcome, not just "check it works") +- Linked to specific stories for UI changes, or to build artifacts when relevant +- Lists areas most likely to regress and worth extra scrutiny + +**Verify your own steps first** — run through them locally before opening the PR. + +When useful, link to published Chromatic Storybooks (CI must finish first; links won't work immediately after opening the PR): + +- Internal UI: `https://--635781f3500dd2c49e189caf.chromatic.com/?path=/story/` +- React Vite TS: `https://--630511d655df72125520f051.chromatic.com/?path=/story/` + +Replace `` with Chromatic's normalized slug (special chars → dashes, e.g. `feature/foo` → `feature-foo`) and `` with the story path (e.g. `example-button--primary`). + ## Command Always create PRs in draft mode: ```bash -gh pr create --draft --title ": " --body "" --label "," +gh pr create --draft --title ": " --body "" --label ",," ``` diff --git a/.agents/skills/rebuild-restart-storybook/SKILL.md b/.agents/skills/rebuild-restart-storybook/SKILL.md new file mode 100644 index 000000000000..1679cd7ad7f1 --- /dev/null +++ b/.agents/skills/rebuild-restart-storybook/SKILL.md @@ -0,0 +1,49 @@ +--- +name: rebuild-restart-storybook +description: Rebuild and restart the internal Storybook UI after changes to internal Storybook code (core, addons, frameworks, renderers, libs, etc.), then optionally display a UI review. Use after editing any package in the Storybook monorepo's code/ directory, or when the user asks to rebuild and/or restart Storybook. +allowed-tools: Bash, Read +--- + +# Rebuild and Restart Storybook + +After making changes to internal Storybook code (core, addons or otherwise), follow this workflow. + +## Step 1: Ask whether to rebuild and restart (in case of model invocation) + +Unless the user explicitly ran `/rebuild-restart-storybook`, ask the user whether they want to rebuild and restart Storybook. If they decline, stop here. + +## Step 2: Determine modified packages + +Build a space-separated list of the monorepo packages that were modified in the conversation. For each modified package, find its Nx project name in the `project.json` file in that package's directory (the `name` field), not the `package.json` name. + +For example, if `code/addons/review` and `code/addons/vitest` were modified, the list is `addon-review addon-vitest`. + +## Step 3: Run the build and start commands in sequence + +Run these in the `code` directory, in order. Substitute `` with the list from Step 2. + +```bash +rm -rf node_modules/.cache +yarn +yarn build storybook +``` + +Then, run in the background: + +```bash +NODE_OPTIONS="--preserve-symlinks" yarn storybook:ui --no-open +``` + +### Handle an occupied port + +A Storybook may already be running. If the port is occupied, cancel the start, kill the old process to free the port, then start Storybook again. + +## Step 4: Provide the URL and ask about a review + +Once Storybook has started, give the user the URL. Then ask whether they want to display a review. + +## Step 5: Display the review + +If the user wants a review, use Storybook's `display-review` MCP tool to create a UI review of relevant stories. Pick a set of stories related to the entirety of the conversation so far. + +If there are no relevant stories because no UI elements were touched, ask the user what they would like to see a review for. diff --git a/.agents/skills/storybook-startup-benchmark/SKILL.md b/.agents/skills/storybook-startup-benchmark/SKILL.md new file mode 100644 index 000000000000..6cd23e6922d8 --- /dev/null +++ b/.agents/skills/storybook-startup-benchmark/SKILL.md @@ -0,0 +1,154 @@ +--- +name: storybook-startup-benchmark +description: Measure Storybook startup time from spawning `storybook dev` until the first story renders in the browser. Use when the user asks about Storybook boot time, server-ready timing, first story render timing, startup regressions, benchmarking with repeat runs, or comparing Storybook versions or feature flags. +allowed-tools: Bash, Read +--- + +# Storybook Startup Benchmark + +## Purpose + +Use this skill to build or explain a repeatable benchmark for Storybook startup: + +- `server`: process spawn -> Storybook server responds +- `browser`: server responds -> first story rendered +- `total`: process spawn -> first story rendered + +## Quick Start + +When asked to benchmark Storybook startup: + +1. Confirm the boundary: + - Start timing immediately before spawning `storybook dev` + - Open Storybook at `/`, not `iframe.html` + - Stop timing on first story mount plus one `requestAnimationFrame()` +2. Check whether the repo already has a benchmark script. Reuse it if possible. +3. If not, create a Node script that: + - starts Storybook with `--no-open` + - waits for the server to respond + - launches a browser it controls + - waits for a preview-side render signal + - prints JSON results +4. Add `--repeat ` support with grouped summary stats. + +## Measurement Rules + +Use these defaults unless the user asks otherwise: + +- Start URL: `/` +- Browser path: normal manager page, let Storybook load the preview iframe itself +- Render signal: first preview story mount + one `requestAnimationFrame()` +- Browser launch: external harness launches the browser, not Storybook +- Auto-open: disable Storybook browser auto-open with `storybook dev --no-open` + +Do not measure only CLI output. Server listening is not the same as first story rendered. + +## Recommended Implementation + +### 1. Preview-side signal + +Add a small global preview decorator or component that runs once on the first story mount: + +- wait one `requestAnimationFrame()` +- set a global value like `window.__sbStartupBenchmark` +- also mirror that value to `window.top` when same-origin +- optionally call `performance.mark('sb:first-story-rendered')` + +Preferred payload: + +```ts +{ + firstStoryRenderedAt: performance.now(), + storyId: id +} +``` + +### 2. Harness behavior + +The benchmark script should: + +1. Fail fast if the target Storybook port is already in use +2. Spawn Storybook with `--no-open` +3. Start timing immediately before spawn +4. Wait for HTTP readiness on the Storybook URL +5. Launch a controlled browser to `/` +6. Wait for the preview-side signal +7. Print JSON results +8. Kill the full spawned process group during cleanup + +### 3. Repeated runs + +For `--repeat N`, print: + +- per-run results +- summary stats grouped by `server`, `browser`, and `total` +- human-readable durations like `5.2s` or `2m15s`, not raw millisecond field names + +Recommended summary fields: + +```json +{ + "server": { + "average": "5.2s", + "min": "4.8s", + "max": "6.1s", + "p95": "6.0s" + }, + "browser": { + "average": "1.9s", + "min": "1.6s", + "max": "2.4s", + "p95": "2.3s" + }, + "total": { + "average": "7.1s", + "min": "6.6s", + "max": "8.2s", + "p95": "8.1s" + } +} +``` + +## Interpretation Guidance + +Use these heuristics when explaining results: + +- If `server` grows, the regression is likely on the server/build side. +- If `browser` grows, the regression is likely in manager boot, preview boot, or first-story render. +- If averages are close but `p95` grows a lot, the feature likely increases variability or tail latency. +- If newer Storybook without a feature is much faster than older Storybook, but enabling the feature returns timings to older levels, the feature likely erases the newer startup gains. + +## Common Pitfalls + +- Existing Storybook already running on the benchmark port +- Storybook auto-opening a separate browser window +- Measuring direct `iframe.html` loads instead of normal `/` +- Leaving child Storybook processes alive between runs +- Treating warm repeated runs as cold-start data + +If a repeated benchmark reports unrealistically low `server.average`, first check for a stale Storybook server on the same port. + +## Tooling Notes + +- Prefer a plain Node script for portability. +- For Chrome-family browsers, remote debugging is a practical control path. +- If Storybook CLI flags or behavior are in question, fetch current Storybook docs first. + +## Output Style + +When reporting results: + +- clearly separate `server`, `browser`, and `total` +- call out averages and p95 first, with min/max as supporting bounds +- highlight whether the regression affects common-case latency, tail latency, or both +- avoid claiming root cause unless the benchmark isolates server vs render behavior + +## Example Triggers + +Use this skill when the user says things like: + +- "How can I test Storybook startup time?" +- "Measure from `storybook dev` to first story render" +- "Compare Storybook startup with and without this feature flag" +- "Run 20 startup measurements and summarize averages" +- "Is this startup regression server-side or render-side?" diff --git a/.agents/skills/update-pr-description/SKILL.md b/.agents/skills/update-pr-description/SKILL.md new file mode 100644 index 000000000000..6ef85183a1cc --- /dev/null +++ b/.agents/skills/update-pr-description/SKILL.md @@ -0,0 +1,26 @@ +--- +name: update-pr-description +description: Evaluate a PR's title and description against its actual implementation, then iteratively suggest and apply updates. Use when the user asks to check, fix, or update a PR title or description. +--- + +# Update PR Description + +## Workflow + +1. **Resolve the PR.** Use the number/URL the user provided. If none, look up the PR for the current branch with `gh pr view`. +2. **Gather evidence:** + - `gh pr view --json title,body` + - `gh pr view --json commits` (commit messages) + - `gh pr diff ` (full diff against base) +3. **Evaluate divergence.** Compare the stated title/description against what the commits and diff actually do. Only flag *meaningful* divergence (wrong scope, missing major changes, stale claims, inaccurate summary). Ignore trivial wording. +4. **Report.** Tell the user whether the title and/or description meaningfully diverges. If not, stop here. +5. **Suggest iteratively.** Propose concrete updated title/description. Ask the user one change at a time whether to apply, accept edits, and refine. +6. **Apply.** Once agreed, update on the user's behalf with `gh pr edit --title ... --body ...`. + +## Notes + +- Match the repository's existing PR template/style if the body uses one. +- Don't rewrite a description that's already accurate. +- Update the state of checkboxes where appropriate. +- Remove section placeholders/reminders when filling out a section. +- Do not remove the canary release section. \ No newline at end of file diff --git a/.circleci/config.yml b/.circleci/config.yml index d2798c319911..72a40b1c00b1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,10 @@ parameters: default: '' description: The PR number type: string + ghTrustedAuthor: + default: 'false' + description: Whether the PR author is a trusted author who should be allowed to persist to shared caches + type: string workflow: default: skipped description: Which workflow to run @@ -30,7 +34,7 @@ parameters: jobs: generate-and-run-config: - executor: + executor: name: node/default resource_class: large steps: @@ -44,7 +48,9 @@ jobs: - run: name: Generate config command: | - yarn dlx jiti ./scripts/ci/main.ts --workflow=<< pipeline.parameters.workflow >> + yarn dlx jiti ./scripts/ci/main.ts \ + --workflow=<< pipeline.parameters.workflow >> \ + --gh-trusted-author=<< pipeline.parameters.ghTrustedAuthor >> - continuation/continue: configuration_path: .circleci/config.generated.yml workflows: diff --git a/.claude/skills/docs-review/SKILL.md b/.claude/skills/docs-review/SKILL.md new file mode 100644 index 000000000000..4d9467c75f91 --- /dev/null +++ b/.claude/skills/docs-review/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/docs-review/SKILL.md diff --git a/.claude/skills/fix-linting-types-on-pr/SKILL.md b/.claude/skills/fix-linting-types-on-pr/SKILL.md new file mode 100644 index 000000000000..58bb71fcb235 --- /dev/null +++ b/.claude/skills/fix-linting-types-on-pr/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/fix-linting-types-on-pr/SKILL.md diff --git a/.claude/skills/handle-pr-comments/SKILL.md b/.claude/skills/handle-pr-comments/SKILL.md new file mode 100644 index 000000000000..bf62f83d35ad --- /dev/null +++ b/.claude/skills/handle-pr-comments/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/handle-pr-comments/SKILL.md diff --git a/.claude/skills/minor-release/SKILL.md b/.claude/skills/minor-release/SKILL.md new file mode 100644 index 000000000000..22985b96089e --- /dev/null +++ b/.claude/skills/minor-release/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/minor-release/SKILL.md diff --git a/.claude/skills/open-pr/SKILL.md b/.claude/skills/open-pr/SKILL.md new file mode 100644 index 000000000000..6b15c5fb612d --- /dev/null +++ b/.claude/skills/open-pr/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/open-pr/SKILL.md diff --git a/.claude/skills/rebuild-restart-storybook/SKILL.md b/.claude/skills/rebuild-restart-storybook/SKILL.md new file mode 100644 index 000000000000..69cbf65ac824 --- /dev/null +++ b/.claude/skills/rebuild-restart-storybook/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/rebuild-restart-storybook/SKILL.md diff --git a/.claude/skills/storybook-startup-benchmark/SKILL.md b/.claude/skills/storybook-startup-benchmark/SKILL.md new file mode 100644 index 000000000000..6c2f8f01e515 --- /dev/null +++ b/.claude/skills/storybook-startup-benchmark/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/storybook-startup-benchmark/SKILL.md diff --git a/.claude/skills/update-pr-description/SKILL.md b/.claude/skills/update-pr-description/SKILL.md new file mode 100644 index 000000000000..dda9474e5153 --- /dev/null +++ b/.claude/skills/update-pr-description/SKILL.md @@ -0,0 +1 @@ +@../../../.agents/skills/update-pr-description/SKILL.md diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 000000000000..94d072a22f2d --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,30 @@ +version = 1 +name = "storybook" + +[setup] +script = "yarn install && yarn nx run-many -t compile" + +[[actions]] +name = "Compile" +icon = "tool" +command = "yarn nx run-many -t compile" + +[[actions]] +name = "Check" +icon = "tool" +command = "yarn nx run-many -t check" + +[[actions]] +name = "Unit Tests" +icon = "test" +command = "yarn test" + +[[actions]] +name = "Internal UI" +icon = "run" +command = "yarn --cwd code storybook:ui" + +[[actions]] +name = "Format" +icon = "tool" +command = "yarn --cwd code fmt:write" diff --git a/.external/addon-svelte-csf b/.external/addon-svelte-csf deleted file mode 160000 index 0ff845efcd43..000000000000 --- a/.external/addon-svelte-csf +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0ff845efcd43373e5418b70ee83fb7325d33e940 diff --git a/.external/addon-webpack5-compiler-babel b/.external/addon-webpack5-compiler-babel deleted file mode 160000 index ae95d5f16854..000000000000 --- a/.external/addon-webpack5-compiler-babel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ae95d5f168548237319071fe67e6cf51bd3981cf diff --git a/.external/addon-webpack5-compiler-swc b/.external/addon-webpack5-compiler-swc deleted file mode 160000 index 3d64e2bf92ea..000000000000 --- a/.external/addon-webpack5-compiler-swc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3d64e2bf92eae064f528ce5e45bdd4ad159ee6a4 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a57d93ef584e..e50fbad8495c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,12 +30,14 @@ Thank you for contributing to Storybook! Please submit all PRs to the `next` bra > [!CAUTION] > This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks! - ### Documentation @@ -49,6 +51,7 @@ Thank you for contributing to Storybook! Please submit all PRs to the `next` bra ## Checklist for Maintainers - [ ] When this PR is ready for testing, make sure to add `ci:normal`, `ci:merged` or `ci:daily` GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in `code/lib/cli-storybook/src/sandbox-templates.ts` +- [ ] Declare whether manual QA will be needed for this PR during the next release, through `qa:needed` or `qa:skip` - [ ] Make sure this PR contains **one** of the labels below: Available labels diff --git a/.github/actions/setup-node-and-install/action.yml b/.github/actions/setup-node-and-install/action.yml index d882d1c57068..fb7fd0a25248 100644 --- a/.github/actions/setup-node-and-install/action.yml +++ b/.github/actions/setup-node-and-install/action.yml @@ -11,7 +11,7 @@ runs: using: 'composite' steps: - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' @@ -19,8 +19,11 @@ runs: shell: bash run: npm install -g npm@latest - - name: Cache dependencies - uses: actions/cache@v4 + # Restore only — save is gated below. actions/cache's post-job save step uses a + # runner-internal token that bypasses `permissions:`, so splitting is the only + # reliable way to prevent pull_request_target runs from poisoning the shared cache. + - name: Restore cached dependencies + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ~/.yarn/berry/cache @@ -39,3 +42,11 @@ runs: shell: bash working-directory: code run: yarn install + + - name: Save cached dependencies + if: github.event_name != 'pull_request_target' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: | + ~/.yarn/berry/cache + key: yarn-v1-${{ hashFiles('yarn.lock') }} diff --git a/.github/scripts/agent-scan-label-pr.mjs b/.github/scripts/agent-scan-label-pr.mjs index 91b4182b36f2..b7908b51f44a 100644 --- a/.github/scripts/agent-scan-label-pr.mjs +++ b/.github/scripts/agent-scan-label-pr.mjs @@ -2,33 +2,29 @@ import * as core from '@actions/core'; import * as github from '@actions/github'; /** - * agent scan classification rename + * agent scan classification name * - organic -> human - * - automated - * - mixed */ const CLASSIFICATION_MAP = { organic: 'human', - automation: 'automated', }; async function main() { const classification = core.getInput('classification', { required: true }); const token = core.getInput('token', { required: true }); - const isCommunityFlagged = core.getInput('community-flagged') === 'true'; const octokit = github.getOctokit(token); const prNumber = github.context.payload.pull_request.number; - const labels = [`agent-scan:${CLASSIFICATION_MAP[classification] ?? classification}`]; - if (isCommunityFlagged) { - labels.push('agent-scan:community-flagged'); + if (classification === 'organic') { + const labels = [`agent-scan:${CLASSIFICATION_MAP[classification] ?? classification}`]; + + await octokit.rest.issues.addLabels({ + ...github.context.repo, + issue_number: prNumber, + labels: labels, + }); } - await octokit.rest.issues.addLabels({ - ...github.context.repo, - issue_number: prNumber, - labels: labels, - }); } main().catch((error) => { diff --git a/.github/workflows/agent-scan.yml b/.github/workflows/agent-scan.yml index badbb0da6a48..d9a44a6b4d86 100644 --- a/.github/workflows/agent-scan.yml +++ b/.github/workflows/agent-scan.yml @@ -1,6 +1,44 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + name: agent-scan +# Start with empty permissions on `pull_request_target`, then set permissions per job as needed. +permissions: {} + on: + # Use `pull_request_target` so we can run this workflow on PRs from forks, as its goal is to assess + # if PR authors are trustworthy. Only reasons on the PR author and does not check out the fork code. + # zizmor: ignore[dangerous-triggers] # required for fork PRs; no fork code is checked out pull_request_target: types: - opened @@ -25,10 +63,17 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - name: Checkout code from `next`/`main` branch (trusted code, not PR author code) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Install script dependencies run: npm install --prefix .github/scripts + - name: Check author org membership id: membership env: @@ -36,24 +81,31 @@ jobs: INPUT_ORG: ${{ github.repository_owner }} INPUT_USERNAME: ${{ github.event.pull_request.user.login }} run: node .github/scripts/agent-scan-check-org-membership.mjs + - name: Cache AgentScan analysis if: steps.membership.outputs.should-scan == 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .agentscan-cache + # Safe because the cache is prefixed and only used here, and does not include + # user-controlled content (can't spoof another actor's identity). key: agentscan-cache-${{ github.actor }} - restore-keys: agentscan-cache- + - name: AgentScan if: steps.membership.outputs.should-scan == 'true' id: agentscan - uses: MatteoGabriele/agentscan-action@a584774dd15cabe6df4c6ab45fc43514a3b56b2d + uses: MatteoGabriele/agentscan-action@f41545309db947a68e22ed2643f182e754f4d41a # v1.8.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} agent-scan-comment: false cache-path: .agentscan-cache + label-community-flagged: 'agent-scan:community-flagged' + label-mixed: 'agent-scan:mixed' + label-automation: 'agent-scan:automated' + - name: Label PR with classification if: steps.membership.outputs.should-scan == 'true' && steps.agentscan.outputs.classification env: INPUT_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_CLASSIFICATION: ${{ steps.agentscan.outputs.classification }} - run: node .github/scripts/agent-scan-label-pr.mjs \ No newline at end of file + run: node .github/scripts/agent-scan-label-pr.mjs diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000000..db6aa408223a --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,35 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +jobs: + claude-review: + # Claude Code reviews are estimated to cost $15-25. use only when necessary and only internally. + if: | + (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') && + contains(github.event.pull_request.labels.*.name, 'claude-review') + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..7921c3a3c925 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,68 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Only org OWNERs and MEMBERs may trigger Claude. author_association is set by + # GitHub on the comment/review/issue payload; everything else (COLLABORATOR, + # CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, NONE) is rejected. + if: | + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && ( + (github.event.action == 'opened' && + contains(fromJSON('["OWNER", "MEMBER"]'), github.event.issue.author_association) && + (contains(github.event.issue.body, '`@claude`') || contains(github.event.issue.title, '`@claude`'))) || + (github.event.action == 'assigned' && github.event.assignee.login == 'claude') + )) + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + assignee_trigger: 'claude' + base_branch: 'next' + prompt: 'Respect the PR description template and repository guidelines in AGENTS.md.' + # Allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Tool permissions Claude needs to follow AGENTS.md (install, compile, + # typecheck, lint, format, test). Bash is NOT allowed by default, so + # without this Claude cannot run any of the repo's build/test commands. + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: | + --allowed-tools "Bash(yarn:*),Bash(cd:*),Bash(node:*),Bash(git status:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*)" + --disallowed-tools "Bash(yarn start),Bash(yarn task dev)" diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 73a7eaad10a9..613f96d5999b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -25,7 +25,9 @@ jobs: # If you do not check out your code, Copilot will do this for you. steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install diff --git a/.github/workflows/cron-weekly.yml b/.github/workflows/cron-weekly.yml deleted file mode 100644 index 26269d89f3ba..000000000000 --- a/.github/workflows/cron-weekly.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Markdown Links Check -# runs every monday at 9 am -on: - schedule: - - cron: "0 9 * * 1" - -permissions: - contents: read # to fetch repository files for markdown link checks - -jobs: - check-links: - if: github.repository_owner == 'storybookjs' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: gaurav-nelson/github-action-markdown-link-check@v1 - # checks all markdown files from important folders including all subfolders - with: - # only show errors that occur instead of successful links + errors - use-quiet-mode: "yes" - # output full HTTP info for broken links - use-verbose-mode: "yes" - config-file: ".github/workflows/markdown-link-check-config.json" - # Notify to Discord channel on failure - - name: Send Discord Notification - if: failure() # Only run this step if previous steps failed - run: | - curl -H "Content-Type: application/json" -X POST -d '{"content":"The Markdown Links Check workflow has failed in the repository: [storybook]"}' ${{ secrets.DISCORD_MONITORING_URL }} diff --git a/.github/workflows/danger-js.yml b/.github/workflows/danger-js.yml index b2f6c6decbe8..2f4f4a6a46d6 100644 --- a/.github/workflows/danger-js.yml +++ b/.github/workflows/danger-js.yml @@ -1,5 +1,41 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + +name: Danger JS + on: - pull_request: + # We need `pull_request_target` to check external contributor PRs. + # zizmor: ignore[dangerous-triggers] # job checks out base.sha (trusted code), not the PR head; see security warning above + pull_request_target: types: - opened - synchronize @@ -8,22 +44,32 @@ on: - unlabeled - edited branches: - - "**" + - '**' concurrency: group: ${{ github.workflow }}-${{ github.event.number }} cancel-in-progress: true -name: Danger JS +permissions: {} + jobs: dangerJS: name: Danger JS runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: write + statuses: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Checkout our own base code, NOT the untrusted PR code. + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false - name: Danger JS - uses: docker://ghcr.io/danger/danger-js:13.0.5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: docker://ghcr.io/danger/danger-js@sha256:66d5394557aef115c9b8086e9886fc24ee32d82ad222efb83778468538a91ef0 # 13.0.5 with: args: --dangerfile scripts/dangerfile.js + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fork-checks.yml b/.github/workflows/fork-checks.yml index 7a7bc9b4dcfe..69f658fad599 100644 --- a/.github/workflows/fork-checks.yml +++ b/.github/workflows/fork-checks.yml @@ -7,15 +7,20 @@ on: env: NODE_OPTIONS: '--max_old_space_size=4096' +permissions: {} + jobs: check: name: Core Type Checking if: github.repository_owner != 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -29,10 +34,13 @@ jobs: name: Core Formatting if: github.repository_owner != 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -40,7 +48,7 @@ jobs: install-code-deps: true - name: oxfmt - run: cd code && yarn lint:fmt + run: cd code && yarn fmt:check test: strategy: @@ -49,10 +57,13 @@ jobs: runs-on: ${{ matrix.os }} name: Core Unit Tests, ${{ matrix.os }} if: github.repository_owner != 'storybookjs' + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install diff --git a/.github/workflows/generate-sandboxes.yml b/.github/workflows/generate-sandboxes.yml index 7548cbada8cc..431edb241922 100644 --- a/.github/workflows/generate-sandboxes.yml +++ b/.github/workflows/generate-sandboxes.yml @@ -14,16 +14,18 @@ env: CLEANUP_SANDBOX_NODE_MODULES: 'true' NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} +permissions: {} + defaults: run: working-directory: ./code - jobs: set-branches: name: Resolve target branches if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} outputs: branches: ${{ steps.set.outputs.branches }} steps: @@ -45,6 +47,8 @@ jobs: needs: set-branches if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: @@ -67,11 +71,12 @@ jobs: /usr/share/dotnet \ /usr/share/swift - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ matrix.branch }} + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: '.nvmrc' @@ -104,13 +109,16 @@ jobs: # publish sandboxes even if the generation fails, as some sandboxes might have been generated successfully # when triggered manually, always publish to the `next` branch on the sandboxes repo if: ${{ !cancelled() }} - run: yarn publish-sandboxes --remote=https://storybook-bot:${{ secrets.PAT_STORYBOOK_BOT }}@github.com/storybookjs/sandboxes.git --push --branch=${{ github.event_name == 'workflow_dispatch' && 'next' || matrix.branch }} + env: + PAT: ${{ secrets.PAT_STORYBOOK_BOT }} + BRANCH: ${{ github.event_name == 'workflow_dispatch' && 'next' || matrix.branch }} + run: yarn publish-sandboxes --remote="https://storybook-bot:${PAT}@github.com/storybookjs/sandboxes.git" --push --branch="$BRANCH" - name: Report failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: | The generation of some or all sandboxes on the **${{ matrix.branch }}** branch has failed. diff --git a/.github/workflows/handle-release-branches.yml b/.github/workflows/handle-release-branches.yml index 021ed04934ff..c39996d4d1de 100644 --- a/.github/workflows/handle-release-branches.yml +++ b/.github/workflows/handle-release-branches.yml @@ -3,28 +3,38 @@ name: Handle Release Branches on: push: +permissions: {} + jobs: branch-checks: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - id: get-branch + env: + REF: ${{ github.ref }} run: | - BRANCH=($(echo ${{ github.ref }} | sed -E 's/refs\/heads\///')) - echo "branch=$BRANCH" >> $GITHUB_ENV + BRANCH="${REF#refs/heads/}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" outputs: - branch: ${{ env.branch }} - is-latest-branch: ${{ env.branch == 'main' }} - is-next-branch: ${{ env.branch == 'next' }} - is-release-branch: ${{ startsWith(env.branch, 'release-') }} - is-actionable-branch: ${{ env.branch == 'main' || env.branch == 'next' || startsWith(env.branch, 'release-') }} + branch: ${{ steps.get-branch.outputs.branch }} + is-latest-branch: ${{ steps.get-branch.outputs.branch == 'main' }} + is-next-branch: ${{ steps.get-branch.outputs.branch == 'next' }} + is-release-branch: ${{ startsWith(steps.get-branch.outputs.branch, 'release-') }} + is-actionable-branch: ${{ steps.get-branch.outputs.branch == 'main' || steps.get-branch.outputs.branch == 'next' || startsWith(steps.get-branch.outputs.branch, 'release-') }} handle-latest: needs: branch-checks if: ${{ needs.branch-checks.outputs.is-latest-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + persist-credentials: false - run: curl -X POST "https://api.netlify.com/build_hooks/${{ secrets.FRONTPAGE_HOOK }}" @@ -32,39 +42,44 @@ jobs: needs: branch-checks if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' || needs.branch-checks.outputs.is-release-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: next path: next + persist-credentials: false - - id: next-version - uses: notiz-dev/github-action-json-property@release - with: - path: ${{ github.workspace }}/next/code/package.json - prop_path: version - - - run: | - NEXT_RELEASE_BRANCH=($(echo ${{ steps.next-version.outputs.prop }} | sed -E 's/([0-9]+)\.([0-9]+).*/release-\1-\2/')) - echo "next-release-branch=$NEXT_RELEASE_BRANCH" >> $GITHUB_ENV + - id: next-release-branch + run: | + NEXT_RELEASE_BRANCH=$(jq -r '.version | capture("^(?[0-9]+)\\.(?[0-9]+)") | "release-\(.maj)-\(.min)"' next/code/package.json) + echo "branch=$NEXT_RELEASE_BRANCH" >> $GITHUB_OUTPUT outputs: - branch: ${{ env.next-release-branch }} + branch: ${{ steps.next-release-branch.outputs.branch }} create-next-release-branch: needs: [branch-checks, get-next-release-branch] if: ${{ needs.branch-checks.outputs.is-next-branch == 'true' }} runs-on: ubuntu-latest + permissions: + contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout (with creds for later git push) + # zizmor: ignore[artipacked] # git push origin requires persisted credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - run: | + - env: + TARGET_BRANCH: ${{ needs.get-next-release-branch.outputs.branch }} + SOURCE_BRANCH: ${{ needs.branch-checks.outputs.branch }} + run: | set +e - REMOTE_BRANCH=$(git branch -r | grep origin/${{ needs.get-next-release-branch.outputs.branch }}) - if [[ ! -z $REMOTE_BRANCH ]]; then git push origin --delete ${{ needs.get-next-release-branch.outputs.branch }}; fi - echo 'Pushing branch ${{ needs.get-next-release-branch.outputs.branch }}...' - git push -f origin ${{ needs.branch-checks.outputs.branch }}:${{ needs.get-next-release-branch.outputs.branch }} + REMOTE_BRANCH=$(git branch -r | grep "origin/${TARGET_BRANCH}") + if [[ -n "$REMOTE_BRANCH" ]]; then git push origin --delete "$TARGET_BRANCH"; fi + echo "Pushing branch ${TARGET_BRANCH}..." + git push -f origin "${SOURCE_BRANCH}:${TARGET_BRANCH}" outputs: branch: ${{ needs.get-next-release-branch.outputs.branch }} @@ -72,30 +87,40 @@ jobs: if: ${{ always() && github.repository_owner == 'storybookjs' }} needs: [branch-checks, get-next-release-branch] runs-on: ubuntu-latest + permissions: {} steps: - - run: | + - id: is-next-release-branch + run: | IS_NEXT_RELEASE_BRANCH=${{ needs.branch-checks.outputs.branch == needs.get-next-release-branch.outputs.branch }} - echo "is-next-release-branch=$IS_NEXT_RELEASE_BRANCH" >> $GITHUB_ENV + echo "result=$IS_NEXT_RELEASE_BRANCH" >> $GITHUB_OUTPUT - - if: ${{ env.is-next-release-branch == 'true' }} - run: echo "relevant-base-branch=next" >> $GITHUB_ENV + - id: relevant-base-branch + if: ${{ steps.is-next-release-branch.outputs.result == 'true' }} + run: echo "relevant-base-branch=next" >> $GITHUB_OUTPUT - - if: ${{ env.is-next-release-branch == 'true' }} + - if: ${{ steps.is-next-release-branch.outputs.result == 'true' }} run: | - echo 'WARNING: Do not push directly to the `${{ needs.branch-checks.outputs.branch }}` branch. This branch is created and force-pushed over after pushing to the `${{ env.relevant-base-branch }}` branch and the changes you just pushed will be lost.' + echo 'WARNING: Do not push directly to the `${{ needs.branch-checks.outputs.branch }}` branch. This branch is created and force-pushed over after pushing to the `${{ steps.relevant-base-branch.outputs.relevant-base-branch }}` branch and the changes you just pushed will be lost.' exit 1 outputs: - check: ${{ env.is-next-release-branch }} + check: ${{ steps.is-next-release-branch.outputs.result }} request-create-frontpage-branch: if: ${{ always() && github.repository_owner == 'storybookjs' }} needs: [branch-checks, next-release-branch-check, create-next-release-branch] runs-on: ubuntu-latest + permissions: + contents: read steps: - if: ${{ needs.branch-checks.outputs.is-actionable-branch == 'true' && needs.branch-checks.outputs.is-latest-branch == 'false' && needs.next-release-branch-check.outputs.check == 'false' }} + env: + BRANCH: ${{ needs.create-next-release-branch.outputs.branch || needs.branch-checks.outputs.branch }} + FRONTPAGE_TOKEN: ${{ secrets.FRONTPAGE_ACCESS_TOKEN }} run: | + DISPATCH_PAYLOAD=$(jq -n --arg branch "$BRANCH" \ + '{event_type: "request-create-frontpage-branch", client_payload: {branch: $branch}}') curl -X POST https://api.github.com/repos/storybookjs/frontpage/dispatches \ - -H 'Accept: application/vnd.github.v3+json' \ - -u ${{ secrets.FRONTPAGE_ACCESS_TOKEN }} \ - --data '{"event_type": "request-create-frontpage-branch", "client_payload": { "branch": "${{ needs.create-next-release-branch.outputs.branch || needs.branch-checks.outputs.branch }}" }}' + -H 'Accept: application/vnd.github.v3+json' \ + -u "$FRONTPAGE_TOKEN" \ + --data "$DISPATCH_PAYLOAD" diff --git a/.github/workflows/markdown-link-check-config.json b/.github/workflows/markdown-link-check-config.json deleted file mode 100644 index 6cdc0a785121..000000000000 --- a/.github/workflows/markdown-link-check-config.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "replacementPatterns": [ - { - "pattern": "^/", - "replacement": "./" - } - ], - "ignorePatterns": [ - { - "pattern": "localhost" - }, - { - "pattern": "https://github.com/storybookjs/storybook/pull/*" - }, - { - "pattern": "https://stackblitz.com/*" - }, - { - "pattern": "https://*.chromatic.com" - }, - { - "pattern": "https://www.chromatic.com/build?*" - }, - { - "pattern": "http://*.nodeca.com" - }, - { - "pattern": "http://definitelytyped.org/*" - }, - { - "pattern": "https://yoursite.com/*" - }, - { - "pattern": "https://my-specific-domain.com" - } - ], - "aliveStatusCodes": [429, 200] -} diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 8afa8c14b353..58c4f15ee567 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -30,45 +30,48 @@ jobs: env: ALL_TASKS: compile,check,knip,test,lint,fmt,sandbox,build,e2e-tests,e2e-tests-dev,test-runner,vitest-integration,check-sandbox,e2e-ui,jest,vitest,playwright-ct steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: filter: tree:0 fetch-depth: 0 + persist-credentials: false - name: Set Nx tag(s) id: tag + env: + EVENT_NAME: ${{ github.event_name }} + IS_MERGED: ${{ contains(github.event.pull_request.labels.*.name, 'ci:merged') }} + IS_DAILY: ${{ contains(github.event.pull_request.labels.*.name, 'ci:daily') }} + REF: ${{ github.ref }} run: | tags="normal" - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci:merged') }}" == "true" ]]; then - tags="merged" - fi - if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci:daily') }}" == "true" ]]; then - tags="daily" - fi + if [[ "$EVENT_NAME" == "pull_request" ]]; then + if [[ "$IS_MERGED" == "true" ]]; then tags="merged"; fi + if [[ "$IS_DAILY" == "true" ]]; then tags="daily"; fi fi - - if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/next" ]]; then + if [[ "$EVENT_NAME" == "push" && "$REF" == "refs/heads/next" ]]; then tags="merged" fi - - if [[ "${{ github.event_name }}" == "schedule" ]]; then + if [[ "$EVENT_NAME" == "schedule" ]]; then tags="daily" fi - echo "tag=$tags" >> "$GITHUB_OUTPUT" - name: Select distribution config id: dist + env: + TAG: ${{ steps.tag.outputs.tag }} run: | - if [[ "${{ steps.tag.outputs.tag }}" == "daily" ]]; then + if [[ "$TAG" == "daily" ]]; then echo "config=./.nx/workflows/distribution-config-daily.yaml" >> "$GITHUB_OUTPUT" else echo "config=./.nx/workflows/distribution-config.yaml" >> "$GITHUB_OUTPUT" fi - - run: npx nx-cloud@latest start-ci-run --distribute-on="${{ steps.dist.outputs.config }}" --stop-agents-after="$ALL_TASKS" + - env: + DIST_CONFIG: ${{ steps.dist.outputs.config }} + run: npx nx-cloud@19.1.3 start-ci-run --distribute-on="$DIST_CONFIG" --stop-agents-after="$ALL_TASKS" - name: Create Nx Cloud Status (pending) - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: - script: | + script: | # zizmor: ignore[template-injection] safe toJson context expansion in github-script const tag = ${{ toJson(steps.tag.outputs.tag) }} || 'normal'; await github.rest.repos.createCommitStatus({ @@ -82,49 +85,51 @@ jobs: description: 'NX Cloud is running your tests', context: `nx: ${tag}`, }); - - uses: actions/setup-node@v4 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 22 + node-version-file: '.nvmrc' cache: 'yarn' - run: yarn install --immutable - - uses: nrwl/nx-set-shas@v4 + - uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 - id: nx name: 'Run nx' + env: + TAG: ${{ steps.tag.outputs.tag }} run: | echo 'nx_output<> "$GITHUB_OUTPUT" - yarn nx run-many -t $ALL_TASKS -c production -p="tag:library,tag:ci:${{ steps.tag.outputs.tag }}" | tee -a "$GITHUB_OUTPUT" + yarn nx run-many -t $ALL_TASKS -c production -p="tag:library,tag:ci:${TAG}" | tee -a "$GITHUB_OUTPUT" status=${PIPESTATUS[0]} echo 'EOF' >> "$GITHUB_OUTPUT" exit $status - name: Create per-task Nx statuses if: always() - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} - script: | + script: | # zizmor: ignore[template-injection] safe toJson context expansion in github-script const raw = ${{ toJson(steps.nx.outputs.nx_output) }} || ''; const tag = ${{ toJson(steps.tag.outputs.tag) }} || ''; const lines = raw.split('\n'); const failures = []; - + for (const [i, line] of lines.entries()) { if (!line.includes('✖')) continue; - + const task = line.match(/✖\s+([^│]+?)\s{2,}/)?.[1].trim() || 'Unknown Nx task'; - + const url = lines .slice(i + 1, i + 6) .find(l => l.includes('Task logs:')) ?.match(/Task logs:\s*(https:\/\/cloud\.nx\.app\/logs\/\S+)/)?.[1]; - + failures.push({ task, url }); } - + const sha = context.payload.pull_request?.head?.sha ?? context.sha; - + // Per-task statuses (max 5) for (const { task, url } of failures.slice(0, 5)) { await github.rest.repos.createCommitStatus({ @@ -137,7 +142,7 @@ jobs: description: 'Your test failed on NX Cloud', }); } - + const runMatches = raw.match(/https:\/\/cloud\.nx\.app\/runs\/\S+/g); const nxCloudUrl = runMatches ? runMatches[runMatches.length - 1] : undefined; @@ -153,4 +158,4 @@ jobs: ? `Nx Cloud run failed (${failedCount} tasks failed)` : 'Nx Cloud run finished successfully', context: `nx: ${tag}`, - }); \ No newline at end of file + }); diff --git a/.github/workflows/prepare-non-patch-release.yml b/.github/workflows/prepare-non-patch-release.yml index 0ea4d5ee51d7..a54599891b58 100644 --- a/.github/workflows/prepare-non-patch-release.yml +++ b/.github/workflows/prepare-non-patch-release.yml @@ -33,18 +33,25 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: prepare-non-patch-pull-request: name: Prepare non-patch pull request if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest environment: Release + permissions: + contents: write + pull-requests: write + actions: write defaults: run: working-directory: scripts steps: - name: Checkout next - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push --force origin uses persisted GH_TOKEN credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: next # this needs to be set to a high enough number that it will contain the last version tag @@ -66,10 +73,11 @@ jobs: if: steps.check-frozen.outputs.frozen == 'true' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" # tags are needed to get changes and changelog generation - name: Fetch git tags @@ -86,63 +94,91 @@ jobs: if: steps.unreleased-changes.outputs.has-changes-to-release == 'false' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Bump version deferred id: bump-version + env: + RELEASE_TYPE: ${{ inputs.release-type || 'prerelease' }} + PRE_ID: ${{ inputs.pre-id }} run: | - yarn release:version --deferred --release-type ${{ inputs.release-type || 'prerelease' }} ${{ inputs.pre-id && format('{0} {1}', '--pre-id', inputs.pre-id) || '' }} --verbose + ARGS=(--deferred --release-type "$RELEASE_TYPE") + if [[ -n "$PRE_ID" ]]; then + ARGS+=(--pre-id "$PRE_ID") + fi + yarn release:version "${ARGS[@]}" --verbose - name: Write changelog env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} run: | - yarn release:write-changelog ${{ steps.bump-version.outputs.next-version }} --verbose + yarn release:write-changelog "$NEXT_VERSION" --verbose - name: 'Commit changes to branch: version-non-patch-from-${{ steps.bump-version.outputs.current-version }}' working-directory: . + env: + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} run: | git config --global user.name 'storybook-bot' git config --global user.email '32066757+storybook-bot@users.noreply.github.com' - git checkout -b version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + git checkout -b "version-non-patch-from-${CURRENT_VERSION}" git add . - git commit --allow-empty --no-verify -m "Write changelog for ${{ steps.bump-version.outputs.next-version }} [skip ci]" - git push --force origin version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + git commit --allow-empty --no-verify -m "Write changelog for ${NEXT_VERSION} [skip ci]" + git push --force origin "version-non-patch-from-${CURRENT_VERSION}" - name: Generate PR description id: description env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:generate-pr-description --current-version ${{ steps.bump-version.outputs.current-version }} --next-version ${{ steps.bump-version.outputs.next-version }} --verbose + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} + run: | + yarn release:generate-pr-description \ + --current-version "$CURRENT_VERSION" \ + --next-version "$NEXT_VERSION" \ + --verbose - name: Create or update pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RELEASE_TYPE: ${{ inputs.release-type || 'prerelease' }} + PRE_ID: ${{ inputs.pre-id }} + NEXT_VERSION: ${{ steps.bump-version.outputs.next-version }} + CURRENT_VERSION: ${{ steps.bump-version.outputs.current-version }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | - RELEASE_TYPE=${{ inputs.release-type || 'prerelease' }} CAPITALIZED_RELEASE_TYPE=${RELEASE_TYPE^} + TITLE_SUFFIX="" + if [[ -n "$PRE_ID" ]]; then + TITLE_SUFFIX="${PRE_ID} " + fi + TITLE="Release: ${CAPITALIZED_RELEASE_TYPE} ${TITLE_SUFFIX}${NEXT_VERSION}" if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}" \ - --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ - --body "${{ steps.description.outputs.description }}" + --repo "$REPO" \ + --title "$TITLE" \ + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}"\ - --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ + --repo "$REPO" \ + --title "$TITLE" \ --label "release" \ --base next-release \ - --head version-non-patch-from-${{ steps.bump-version.outputs.current-version }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-non-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Report job failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: 'The GitHub Action for preparing the release pull request bumping from v${{ steps.bump-version.outputs.current-version }} to v${{ steps.bump-version.outputs.next-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index fb7eaa96288f..7f6c0ea045c8 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -15,18 +15,25 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: prepare-patch-pull-request: name: Prepare patch pull request if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest environment: Release + permissions: + contents: write + pull-requests: write + actions: write defaults: run: working-directory: scripts steps: - name: Checkout main - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push --force origin uses persisted GH_TOKEN credentials + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main token: ${{ secrets.GH_TOKEN }} @@ -45,10 +52,11 @@ jobs: if: steps.check-frozen.outputs.frozen == 'true' && github.event_name != 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Check for unreleased changes id: unreleased-changes @@ -75,10 +83,11 @@ jobs: if: steps.pick-patches.outputs.no-patch-prs == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.run_id }} # From https://stackoverflow.com/a/75809743 run: | - gh run cancel ${{ github.run_id }} - gh run watch ${{ github.run_id }} + gh run cancel "$RUN_ID" + gh run watch "$RUN_ID" - name: Bump version deferred id: bump-version @@ -95,77 +104,101 @@ jobs: - name: Set version output id: versions + env: + BUMP_CURRENT: ${{ steps.bump-version.outputs.current-version }} + BUMP_NEXT: ${{ steps.bump-version.outputs.next-version }} + CUR_CURRENT: ${{ steps.current-version.outputs.current-version }} run: | - echo "current=${{ steps.bump-version.outputs.current-version || steps.current-version.outputs.current-version }}" >> "$GITHUB_OUTPUT" - echo "next=${{ steps.bump-version.outputs.next-version || steps.current-version.outputs.current-version }}" >> "$GITHUB_OUTPUT" + echo "current=${BUMP_CURRENT:-$CUR_CURRENT}" >> "$GITHUB_OUTPUT" + echo "next=${BUMP_NEXT:-$CUR_CURRENT}" >> "$GITHUB_OUTPUT" - name: Write changelog if: steps.unreleased-changes.outputs.has-changes-to-release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} run: | - yarn release:write-changelog ${{ steps.versions.outputs.next }} --unpicked-patches --verbose + yarn release:write-changelog "$NEXT_VERSION" --unpicked-patches --verbose - name: 'Commit changes to branch: version-patch-from-${{ steps.versions.outputs.current }}' working-directory: . + env: + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} run: | git config --global user.name 'storybook-bot' git config --global user.email '32066757+storybook-bot@users.noreply.github.com' - git checkout -b version-patch-from-${{ steps.versions.outputs.current }} + git checkout -b "version-patch-from-${CURRENT_VERSION}" git add . - git commit --allow-empty --no-verify -m "Write changelog for ${{ steps.versions.outputs.next }} [skip ci]" - git push --force origin version-patch-from-${{ steps.versions.outputs.current }} + git commit --allow-empty --no-verify -m "Write changelog for ${NEXT_VERSION} [skip ci]" + git push --force origin "version-patch-from-${CURRENT_VERSION}" - name: Generate PR description id: description env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:generate-pr-description --unpicked-patches --manual-cherry-picks='${{ steps.pick-patches.outputs.failed-cherry-picks }}' ${{ steps.unreleased-changes.outputs.has-changes-to-release == 'true' && format('{0}={1} {2}={3}', '--current-version', steps.versions.outputs.current, '--next-version', steps.versions.outputs.next) || '' }} --verbose + HAS_CHANGES: ${{ steps.unreleased-changes.outputs.has-changes-to-release }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} + FAILED_PICKS: ${{ steps.pick-patches.outputs.failed-cherry-picks }} + run: | + ARGS=(--unpicked-patches --manual-cherry-picks="$FAILED_PICKS") + if [[ "$HAS_CHANGES" == "true" ]]; then + ARGS+=(--current-version "$CURRENT_VERSION" --next-version "$NEXT_VERSION") + fi + yarn release:generate-pr-description "${ARGS[@]}" --verbose - name: Create or update pull request with release if: steps.unreleased-changes.outputs.has-changes-to-release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + NEXT_VERSION: ${{ steps.versions.outputs.next }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}" \ - --title "Release: Patch ${{ steps.versions.outputs.next }}" \ - --body "${{ steps.description.outputs.description }}" + --repo "$REPO" \ + --title "Release: Patch ${NEXT_VERSION}" \ + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}" \ - --title "Release: Patch ${{ steps.versions.outputs.next }}" \ + --repo "$REPO" \ + --title "Release: Patch ${NEXT_VERSION}" \ --label "release" \ --base latest-release \ - --head version-patch-from-${{ steps.versions.outputs.current }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Create or update pull request without release if: steps.unreleased-changes.outputs.has-changes-to-release == 'false' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + CURRENT_VERSION: ${{ steps.versions.outputs.current }} + DESCRIPTION: ${{ steps.description.outputs.description }} run: | if PR_STATE=$(gh pr view --json state --jq .state 2>/dev/null) && [[ -n "$PR_STATE" && "$PR_STATE" == *"OPEN"* ]]; then gh pr edit \ - --repo "${{github.repository }}"\ + --repo "$REPO" \ --title "Release: Merge patches to \`main\` (without version bump)" \ - --body "${{ steps.description.outputs.description }}" + --body "$DESCRIPTION" else gh pr create \ - --repo "${{github.repository }}"\ + --repo "$REPO" \ --title "Release: Merge patches to \`main\` (without version bump)" \ --label "release" \ --base latest-release \ - --head version-patch-from-${{ steps.versions.outputs.current }} \ - --body "${{ steps.description.outputs.description }}" + --head "version-patch-from-${CURRENT_VERSION}" \ + --body "$DESCRIPTION" fi - name: Report job failure to Discord if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: args: 'The GitHub Action for preparing the release pull request bumping from v${{ steps.versions.outputs.current }} to v${{ steps.versions.outputs.next }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 853fc99af401..f588fe4c6eaa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,28 +11,24 @@ on: # Manual canary releases on PRs inputs: pr: - description: "⚠️ CANARY RELEASES ONLY - Enter the pull request number to create a canary release for" + description: '⚠️ CANARY RELEASES ONLY - Enter the pull request number to create a canary release for' required: true type: number - pull_request: - # Automated canary releases on PRs with the "with-canary-release"-suffix in the branch name - types: [opened, synchronize, reopened] env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 1 -permissions: - id-token: write - contents: write - pull-requests: write +permissions: {} concurrency: - # Group concurrent runs based on the event type: - # - For workflow_dispatch and pull_request: group by PR number to allow only one canary release per PR - # - For push events: group by branch name to prevent multiple releases on the same branch - group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow, inputs.pr) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.ref_name) }} - cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' }} + # Group concurrent runs to control cancellation per release type: + # - Canary releases (manual `workflow_dispatch`) are grouped by PR number, + # so a newer canary supersedes an older one for the same PR. Only runs + # that actually publish a canary join this group. + # - Pushes to release branches are grouped by branch name and never cancelled. + group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', github.workflow, inputs.pr) || format('{0}-{1}', github.workflow, github.ref_name) }} + cancel-in-progress: ${{ github.event_name == 'workflow_dispatch'}} jobs: publish-normal: @@ -44,12 +40,18 @@ jobs: (github.ref_name == 'latest-release' || github.ref_name == 'next-release') && contains(github.event.head_commit.message, '[skip ci]') != true environment: Release + permissions: + contents: write + issues: write + actions: write # required for yarn release:cancel-preparation-runs + id-token: write # required for npm provenance via yarn release:publish defaults: run: working-directory: scripts steps: - name: Checkout ${{ github.ref_name }} - uses: actions/checkout@v4 + # zizmor: ignore[artipacked] # git push origin runs at multiple steps using persisted GH_TOKEN + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 100 token: ${{ secrets.GH_TOKEN }} @@ -186,7 +188,7 @@ jobs: - name: Create Sentry release if: steps.publish-needed.outputs.published == 'false' - uses: getsentry/action-release@v3 + uses: getsentry/action-release@5657c9e888b4e2cc85f4d29143ea4131fde4a73a # v3.6.0 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} @@ -199,24 +201,25 @@ jobs: if: failure() env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_MONITORING_URL }} - uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 + uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # v0.4.0 with: - args: "The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + args: 'The GitHub Action for publishing version ${{ steps.version.outputs.current-version }} (triggered by ${{ github.triggering_actor }}) failed! See run at: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}' publish-canary: name: Publish canary version runs-on: ubuntu-latest if: | github.repository_owner == 'storybookjs' && - ( - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && endsWith(github.head_ref, 'with-canary-release')) - ) && + github.event_name == 'workflow_dispatch' && contains(github.event.head_commit.message, '[skip ci]') != true environment: Release + permissions: + contents: read + pull-requests: write + id-token: write # required for npm provenance via yarn release:publish steps: - name: Fail if triggering actor is not administrator - uses: prince-chrismc/check-actor-permissions-action@87c6d9b36c730377858fd9719fbbac1b58fa678d + uses: prince-chrismc/check-actor-permissions-action@87c6d9b36c730377858fd9719fbbac1b58fa678d # no version attached, ahead of last release with: permission: admin @@ -224,7 +227,7 @@ jobs: id: info env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} REPOSITORY: ${{ github.repository }} run: | PR_INFO=$(gh pr view "$PR_NUMBER" --repo "$REPOSITORY" --json isCrossRepository,headRefOid,headRefName,headRepository,headRepositoryOwner --jq '{isFork: .isCrossRepository, owner: .headRepositoryOwner.login, repoName: .headRepository.name, branch: .headRefName, sha: .headRefOid}') @@ -240,11 +243,12 @@ jobs: echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: ${{ steps.info.outputs.isFork == 'true' && steps.info.outputs.repository || null }} ref: ${{ steps.info.outputs.sha }} token: ${{ secrets.GH_TOKEN }} + persist-credentials: false - name: Setup Node.js and Install Dependencies uses: ./.github/actions/setup-node-and-install @@ -255,7 +259,7 @@ jobs: id: version working-directory: scripts env: - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} SHORT_SHA: ${{ steps.info.outputs.shortSha }} run: | yarn release:version --exact "0.0.0-pr-$PR_NUMBER-sha-$SHORT_SHA" --verbose @@ -267,11 +271,11 @@ jobs: run: yarn release:publish --tag canary --verbose - name: Replace Pull Request Body - uses: ivangabriele/find-and-replace-pull-request-body@042438c6cbfbacf6a4701d6042f59b1f73db2fd8 + uses: ivangabriele/find-and-replace-pull-request-body@042438c6cbfbacf6a4701d6042f59b1f73db2fd8 # no version attached, ahead of last release with: githubToken: ${{ secrets.GH_TOKEN }} prNumber: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || '' }} - find: "CANARY_RELEASE_SECTION" + find: 'CANARY_RELEASE_SECTION' isHtmlCommentTag: true replace: | This pull request has been released as version `${{ steps.version.outputs.next-version }}`. Try it out in a new sandbox by running `npx storybook@${{ steps.version.outputs.next-version }} sandbox` or in an existing project with `npx storybook@${{ steps.version.outputs.next-version }} upgrade`. @@ -290,14 +294,14 @@ jobs: To request a new release of this pull request, mention the `@storybookjs/core` team. - _core team members can create a new canary release [here](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) or locally with `gh workflow run --repo storybookjs/storybook publish.yml --field pr=${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }}`_ + _core team members can create a new canary release [here](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) or locally with `gh workflow run --repo storybookjs/storybook publish.yml --field pr=${{ github.event_name == 'workflow_dispatch' && inputs.pr }}`_ - name: Create failing comment on PR if: failure() env: GH_TOKEN: ${{ secrets.GH_TOKEN }} - PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr || github.event.pull_request.number }} + PR_NUMBER: ${{ github.event_name == 'workflow_dispatch' && inputs.pr }} REPOSITORY: ${{ github.repository }} TRIGGERING_ACTOR: ${{ github.triggering_actor }} RUN_ID: ${{ github.run_id }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 55a49c5f04d2..0e3b950f25b4 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,7 +1,7 @@ -name: "Close stale issues that need reproduction or more info from OP" +name: 'Close stale issues that need reproduction or more info from OP' on: schedule: - - cron: "30 1 * * *" + - cron: '30 1 * * *' permissions: issues: write # to close and label issues (actions/stale) @@ -12,13 +12,13 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'storybookjs' steps: - - uses: actions/stale@v9 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: stale-issue-message: "Hi there! Thank you for opening this issue, but it has been marked as `stale` because we need more information to move forward. Could you please provide us with the requested reproduction or additional information that could help us better understand the problem? We'd love to resolve this issue, but we can't do it without your help!" close-issue-message: "I'm afraid we need to close this issue for now, since we can't take any action without the requested reproduction or additional information. But please don't hesitate to open a new issue if the problem persists – we're always happy to help. Thanks so much for your understanding." - any-of-issue-labels: "needs reproduction,needs more info" - exempt-issue-labels: "needs triage" - labels-to-add-when-unstale: "needs triage" + any-of-issue-labels: 'needs reproduction,needs more info' + exempt-issue-labels: 'needs triage' + labels-to-add-when-unstale: 'needs triage' days-before-issue-close: 7 days-before-issue-stale: 21 days-before-pr-close: -1 diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index af45b109da06..25642fd6a4e6 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -18,14 +18,14 @@ jobs: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest steps: - - uses: balazsorban44/nissuer@1.10.0 + - uses: balazsorban44/nissuer@92ef22afd6a75e5e588f5d689a1fd3433f596f82 # v1.10.0 with: label-comments: | { "good first issue": ".github/comments/good-first-issue.md" } - reproduction-comment: ".github/comments/invalid-link.md" - reproduction-hosts: "github.com,codesandbox.io,stackblitz.com" - reproduction-link-section: "### Reproduction link(.*)### Reproduction steps" - reproduction-invalid-label: "needs reproduction" - reproduction-issue-labels: "bug,needs triage" + reproduction-comment: '.github/comments/invalid-link.md' + reproduction-hosts: 'github.com,codesandbox.io,stackblitz.com' + reproduction-link-section: '### Reproduction link(.*)### Reproduction steps' + reproduction-invalid-label: 'needs reproduction' + reproduction-issue-labels: 'bug,needs triage' diff --git a/.github/workflows/trigger-circle-ci-workflow.yml b/.github/workflows/trigger-circle-ci-workflow.yml index 7b1cedda19f1..d2f16bf0ec28 100644 --- a/.github/workflows/trigger-circle-ci-workflow.yml +++ b/.github/workflows/trigger-circle-ci-workflow.yml @@ -1,8 +1,42 @@ +################################################################################################### +# # +# ██ # +# ██░░██ # +# ░░ ░░ ██░░░░░░██ ░░░░ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░██████░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░██████░░░░░░░░██ # +# ██░░░░░░░░░░██████░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░██████░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░██ # +# ██░░░░░░░░░░░░░░░░░░██████░░░░░░░░░░░░░░░░░░██ # +# ░░ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░██ # +# ██████████████████████████████████████████ # +# # +# # +# SECURITY WARNING: Ensure your `pull_request_target` job respects the following rules: # +# # +# - Never write to GitHub Actions cache, as it would allow cache poisoning attacks # +# - Only call third-party systems that are aware the code passed to them could be untrustworthy # +# - Always set explicit permissions on your PR to limit the capabilities of secrets.GITHUB_TOKEN # +# # +################################################################################################### + name: Trigger CircleCI workflow +# Start with empty permissions on `pull_request_target`, then set permissions per job as needed. +permissions: {} + on: - # Use pull_request_target, as we don't need to check out the actual code of the fork in this script. - # And this is the only way to trigger the Circle CI API on forks as well. + # zizmor: ignore[dangerous-triggers] # required for fork PRs; no fork code is checked out — only the Circle CI API is called pull_request_target: types: [opened, synchronize, labeled, reopened] push: @@ -10,59 +44,124 @@ on: - next - main +# For PR events, group per-PR (github.ref is refs/pull//merge) so redundant +# re-triggers on the same PR cancel each other. For pushes to a shared branch +# (next/main), key on the commit SHA instead: otherwise two merges landing close +# together share the refs/heads/ group and the earlier merge's trigger is +# cancelled before it can POST to CircleCI, silently skipping its `merged` workflow. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.event.pull_request.number }} cancel-in-progress: true jobs: get-branch: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - id: get-branch env: - # Stored as environment variable to prevent script injection REF_NAME: ${{ github.ref_name }} PR_REF_NAME: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + EVENT_NAME: ${{ github.event_name }} run: | - if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then - export BRANCH=pull/${{ github.event.pull_request.number }}/head - elif [ "${{ github.event_name }}" = "push" ]; then - export BRANCH="$REF_NAME" - else - export BRANCH="$PR_REF_NAME" + if [ "$IS_FORK" = "true" ]; then + BRANCH="pull/${PR_NUMBER}/head" + elif [ "$EVENT_NAME" = "push" ]; then + BRANCH="$REF_NAME" + else + BRANCH="$PR_REF_NAME" fi echo "$BRANCH" - echo "branch=$BRANCH" >> $GITHUB_ENV + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" outputs: - branch: ${{ env.branch }} + branch: ${{ steps.get-branch.outputs.branch }} get-parameters: if: github.repository_owner == 'storybookjs' runs-on: ubuntu-latest + permissions: {} steps: - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:normal')) - run: echo "workflow=normal" >> $GITHUB_ENV - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:docs')) - run: echo "workflow=docs" >> $GITHUB_ENV - - if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci:merged') - run: echo "workflow=merged" >> $GITHUB_ENV - - if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily')) - run: echo "workflow=daily" >> $GITHUB_ENV + - id: normal + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:normal')) + run: echo "workflow=normal" >> "$GITHUB_OUTPUT" + - id: docs + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:docs')) + run: echo "workflow=docs" >> "$GITHUB_OUTPUT" + - id: merged + if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'ci:merged') + run: echo "workflow=merged" >> "$GITHUB_OUTPUT" + - id: daily + if: github.event_name == 'pull_request_target' && (contains(github.event.pull_request.labels.*.name, 'ci:daily')) + run: echo "workflow=daily" >> "$GITHUB_OUTPUT" + - id: trusted-author + env: + EVENT_NAME: ${{ github.event_name }} + USER_TYPE: ${{ github.event.pull_request.user.type }} + USER_LOGIN: ${{ github.event.pull_request.user.login }} + GITHUB_API_TOKEN: ${{ secrets.STORYBOOKJS_ORG_MEMBERSHIP_TOKEN }} + run: | + # You can only push to `main` and `next` as a core team member, so the content is trustworthy. + if [ "$EVENT_NAME" = "push" ]; then + echo "result=true" >> "$GITHUB_OUTPUT" + # These commits are made by the release actions, which are gated to core team members. + elif [ "$USER_LOGIN" = "github-actions[bot]" ] && [ "$USER_TYPE" = "Bot" ]; then + echo "result=true" >> "$GITHUB_OUTPUT" + else + # Explicitly trust only members of specific Storybook teams. + # Team slugs for: Maintainers, Core, Developer Experience. + TRUSTED_TEAMS="maintainers core developer-experience" + IS_TRUSTED=false + + for TEAM in $TRUSTED_TEAMS; do + STATE=$(curl -fsSL \ + -H "Authorization: Bearer $GITHUB_API_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/orgs/storybookjs/teams/$TEAM/memberships/$USER_LOGIN" \ + | jq -r '.state' 2>/dev/null || true) + + if [ "$STATE" = "active" ]; then + IS_TRUSTED=true + break + fi + done + + echo "result=$IS_TRUSTED" >> "$GITHUB_OUTPUT" + fi outputs: - workflow: ${{ env.workflow }} + workflow: ${{ steps.normal.outputs.workflow || steps.docs.outputs.workflow || steps.merged.outputs.workflow || steps.daily.outputs.workflow }} ghBaseBranch: ${{ github.event.pull_request.base.ref }} ghPrNumber: ${{ github.event.pull_request.number }} + ghTrustedAuthor: ${{ steps.trusted-author.outputs.result }} trigger-circle-ci-workflow: runs-on: ubuntu-latest needs: [get-branch, get-parameters] if: github.repository_owner == 'storybookjs' && needs.get-parameters.outputs.workflow != '' + permissions: {} steps: - - name: Trigger Normal tests - uses: fjogeleit/http-request-action@v1 - with: - url: 'https://circleci.com/api/v2/project/gh/storybookjs/storybook/pipeline' - method: 'POST' - customHeaders: '{"Content-Type": "application/json", "Circle-Token": "${{ secrets.CIRCLE_CI_TOKEN }}"}' - data: '{ "branch": "${{needs.get-branch.outputs.branch}}", "parameters": ${{toJson(needs.get-parameters.outputs)}} }' + - name: Trigger CircleCI pipeline + env: + CIRCLE_CI_TOKEN: ${{ secrets.CIRCLE_CI_TOKEN }} + BRANCH: ${{ needs.get-branch.outputs.branch }} + WORKFLOW: ${{ needs.get-parameters.outputs.workflow }} + GH_BASE_BRANCH: ${{ needs.get-parameters.outputs.ghBaseBranch }} + GH_PR_NUMBER: ${{ needs.get-parameters.outputs.ghPrNumber }} + GH_TRUSTED_AUTHOR: ${{ needs.get-parameters.outputs.ghTrustedAuthor }} + run: | + PARAMETERS=$(jq -nc \ + --arg workflow "$WORKFLOW" \ + --arg ghBaseBranch "$GH_BASE_BRANCH" \ + --arg ghPrNumber "$GH_PR_NUMBER" \ + --arg ghTrustedAuthor "$GH_TRUSTED_AUTHOR" \ + '{workflow: $workflow, ghBaseBranch: $ghBaseBranch, ghPrNumber: $ghPrNumber, ghTrustedAuthor: $ghTrustedAuthor}') + PAYLOAD=$(jq -nc --arg branch "$BRANCH" --argjson parameters "$PARAMETERS" \ + '{branch: $branch, parameters: $parameters}') + curl -sS --fail-with-body -X POST \ + -H "Content-Type: application/json" \ + -H "Circle-Token: $CIRCLE_CI_TOKEN" \ + -d "$PAYLOAD" \ + "https://circleci.com/api/v2/project/gh/storybookjs/storybook/pipeline" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 000000000000..aa6e37c7b2d7 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,36 @@ +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ['main', 'next', 'next-release', 'latest-release', 'release-*'] + pull_request: + branches: ['**'] + +permissions: + contents: read + security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files. + +jobs: + zizmor: + name: zizmor latest via PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + enable-cache: false + + - name: Run zizmor 🌈 + run: uvx zizmor --format=sarif . > results.sarif + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + if: github.repository_owner == 'storybookjs' + with: + sarif_file: results.sarif + category: zizmor diff --git a/.gitignore b/.gitignore index c8417670f741..a289b7e25180 100644 --- a/.gitignore +++ b/.gitignore @@ -75,7 +75,9 @@ code/core/report .junie CLAUDE.local.md -.claude +.claude/* +!.claude/skills/ +!.claude/skills/** .cursor/mcp.json .vscode/mcp.json .mcp.json diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100644 index 000000000000..967bc20ecb99 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,3 @@ +if [ -z "$SKIP_STORYBOOK_GIT_HOOKS" ] && [ "$STORYBOOK_COMPILE_ON_CHECKOUT" = "true" ]; then + yarn && yarn task compile -s compile +fi diff --git a/.nvmrc b/.nvmrc index ddeb00c1678b..cde04c9af5a2 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1,2 +1,2 @@ -22.22.1 +22.22.3 diff --git a/.rollout-repos/addon-coverage b/.rollout-repos/addon-coverage deleted file mode 160000 index 0279cd63a671..000000000000 --- a/.rollout-repos/addon-coverage +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0279cd63a671d1c35e3c45b4ae35d2f6e0a39ab6 diff --git a/.rollout-repos/addon-designs b/.rollout-repos/addon-designs deleted file mode 160000 index 50824a3e12c5..000000000000 --- a/.rollout-repos/addon-designs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 50824a3e12c5004443695f3d58955c884876458e diff --git a/.rollout-repos/addon-kit b/.rollout-repos/addon-kit deleted file mode 160000 index e17a0f15cab7..000000000000 --- a/.rollout-repos/addon-kit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e17a0f15cab7c0ea6fd9e9c5bc0cd6a8f24941ca diff --git a/.rollout-repos/addon-styling-webpack b/.rollout-repos/addon-styling-webpack deleted file mode 160000 index 7df09eb6d759..000000000000 --- a/.rollout-repos/addon-styling-webpack +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7df09eb6d7596f03d07aefda1252c707ba488d65 diff --git a/.rollout-repos/addon-visual-tests b/.rollout-repos/addon-visual-tests deleted file mode 160000 index be1829a2403b..000000000000 --- a/.rollout-repos/addon-visual-tests +++ /dev/null @@ -1 +0,0 @@ -Subproject commit be1829a2403b2bd55ddc694db00fd0f6ade3007d diff --git a/.rollout-repos/addon-webpack5-compiler-babel b/.rollout-repos/addon-webpack5-compiler-babel deleted file mode 160000 index 5d9ade95a91d..000000000000 --- a/.rollout-repos/addon-webpack5-compiler-babel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5d9ade95a91dd90e023bc471ce82c1eba2c19988 diff --git a/.rollout-repos/addon-webpack5-compiler-swc b/.rollout-repos/addon-webpack5-compiler-swc deleted file mode 160000 index 5af31673f84b..000000000000 --- a/.rollout-repos/addon-webpack5-compiler-swc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5af31673f84b656f19de0ee799ce691ec1ee2d49 diff --git a/.rollout-repos/icons b/.rollout-repos/icons deleted file mode 160000 index 70f13df022f8..000000000000 --- a/.rollout-repos/icons +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 70f13df022f8a0a8ccc15ad358df967ddc2c2e5d diff --git a/.rollout-repos/telejson b/.rollout-repos/telejson deleted file mode 160000 index 78136d94add2..000000000000 --- a/.rollout-repos/telejson +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 78136d94add2f441c2321cebd95ff9a793893828 diff --git a/.rollout-repos/test-runner b/.rollout-repos/test-runner deleted file mode 160000 index c1be8e0ebcb2..000000000000 --- a/.rollout-repos/test-runner +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c1be8e0ebcb2ba1e3e7374f14a2e89c120339fb2 diff --git a/.rollout-repos/vite-plugin-storybook-nextjs b/.rollout-repos/vite-plugin-storybook-nextjs deleted file mode 160000 index bbbb87a985dd..000000000000 --- a/.rollout-repos/vite-plugin-storybook-nextjs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bbbb87a985dd72f5c1f5a18dc3b3e73f650e8b6a diff --git a/.vscode/settings.json b/.vscode/settings.json index ac32fd688694..a4b94099da83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,9 +11,6 @@ "[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, - "[typescript]": { - "editor.defaultFormatter": "oxc.oxc-vscode" - }, "[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" }, @@ -29,7 +26,9 @@ }, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", - "source.fixAll.oxc": "explicit" + "source.fixAll.oxc": "explicit", + "source.organizeImports": "never", + "source.sortImports": "explicit" }, "editor.tabSize": 2, "eslint.options": { @@ -61,8 +60,6 @@ "typescript.preferences.quoteStyle": "single", "typescript.preferGoToSourceDefinition": true, "typescript.tsdk": "./node_modules/typescript/lib", - "vitest.workspaceConfig": "./code/vitest.workspace.ts", - "vitest.rootConfig": "./code/vitest.workspace.ts", "oxc.fmt.configPath": ".oxfmtrc.json", "oxc.enable.oxlint": false } diff --git a/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch b/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch new file mode 100644 index 000000000000..4c53827bc11d --- /dev/null +++ b/.yarn/patches/@7rulnik-react-element-to-jsx-string-npm-15.0.1-e53b67c4b3.patch @@ -0,0 +1,34 @@ +diff --git a/dist/cjs/index.js b/dist/cjs/index.js +index 6d1961830f978c0f3d90cc864003b0c50eb98f0d..9d48f1990ef440431e0e12ebf7560912893cd725 100644 +--- a/dist/cjs/index.js ++++ b/dist/cjs/index.js +@@ -373,11 +373,7 @@ var formatProp = (function (name, hasValue, value, hasDefaultValue, defaultValue + var attributeFormattedInline = ' '; + var attributeFormattedMultiline = "\n".concat(spacer(lvl + 1, tabStop)); + var isMultilineAttribute = formattedPropValue.includes('\n'); +- if (useBooleanShorthandSyntax && formattedPropValue === '{false}' && !hasDefaultValue) { +- // If a boolean is false and not different from it's default, we do not render the attribute +- attributeFormattedInline = ''; +- attributeFormattedMultiline = ''; +- } else if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { ++ if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { + attributeFormattedInline += "".concat(name); + attributeFormattedMultiline += "".concat(name); + } else { +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 1c23d38bc15c76c6f9277c39b362f01e99e561ae..4e2de641a3ddb5851f7afa18c74d5a0f22d1c364 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -347,11 +347,7 @@ var formatProp = (function (name, hasValue, value, hasDefaultValue, defaultValue + var attributeFormattedInline = ' '; + var attributeFormattedMultiline = "\n".concat(spacer(lvl + 1, tabStop)); + var isMultilineAttribute = formattedPropValue.includes('\n'); +- if (useBooleanShorthandSyntax && formattedPropValue === '{false}' && !hasDefaultValue) { +- // If a boolean is false and not different from it's default, we do not render the attribute +- attributeFormattedInline = ''; +- attributeFormattedMultiline = ''; +- } else if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { ++ if (useBooleanShorthandSyntax && formattedPropValue === '{true}') { + attributeFormattedInline += "".concat(name); + attributeFormattedMultiline += "".concat(name); + } else { diff --git a/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch b/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch new file mode 100644 index 000000000000..2855ae658e01 --- /dev/null +++ b/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch @@ -0,0 +1,78 @@ +diff --git a/dist/private/overlays/calculatePosition.cjs b/dist/private/overlays/calculatePosition.cjs +index 379d9a4c18e222ca6320b70af39cd22c0caf48f3..633da26c8799a89b528918c7f10c0bca7167e119 100644 +--- a/dist/private/overlays/calculatePosition.cjs ++++ b/dist/private/overlays/calculatePosition.cjs +@@ -356,7 +356,7 @@ function $43b053bcc899501d$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/overlays/calculatePosition.js b/dist/private/overlays/calculatePosition.js +index aa585640a389c7a82312fa2a42e0f9cccd488c5f..074cac3333c8e8f8df81cc0db0218ef197dc8828 100644 +--- a/dist/private/overlays/calculatePosition.js ++++ b/dist/private/overlays/calculatePosition.js +@@ -365,7 +365,7 @@ function $ba2991ee81d3897a$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/overlays/calculatePosition.mjs b/dist/private/overlays/calculatePosition.mjs +index ff3d7c3d7ba24eeed43ab6a9b1b98533ea7e381c..d0acfa9672a11df4ad135d68bb3c726be57eefed 100644 +--- a/dist/private/overlays/calculatePosition.mjs ++++ b/dist/private/overlays/calculatePosition.mjs +@@ -349,7 +349,7 @@ function $954926fb6168ae2a$export$4b834cebd9e5cebe(node, ignoreScale) { + let { top: top, left: left, width: width, height: height } = node.getBoundingClientRect(); + // Use offsetWidth and offsetHeight if this is an HTML element, so that + // the size is not affected by scale transforms. +- if (ignoreScale && node instanceof node.ownerDocument.defaultView.HTMLElement) { ++ if (ignoreScale && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + width = node.offsetWidth; + height = node.offsetHeight; + } +diff --git a/dist/private/utils/isFocusable.cjs b/dist/private/utils/isFocusable.cjs +index 429761d9841dfebe7b7e49267ff7fa34a1b009b9..cb6d7448e3c725c858d7ad63d257276ffb3c0633 100644 +--- a/dist/private/utils/isFocusable.cjs ++++ b/dist/private/utils/isFocusable.cjs +@@ -46,7 +46,7 @@ function $48f566b6becd50da$export$bebd5a1431fec25d(element) { + function $48f566b6becd50da$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; +diff --git a/dist/private/utils/isFocusable.js b/dist/private/utils/isFocusable.js +index de779817e244ab97ec267d297089555d10a85511..0de6cb0af8254988cecf66a0871047f23100ba14 100644 +--- a/dist/private/utils/isFocusable.js ++++ b/dist/private/utils/isFocusable.js +@@ -39,7 +39,7 @@ function $ee5e22534121197a$export$bebd5a1431fec25d(element) { + function $ee5e22534121197a$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; +diff --git a/dist/private/utils/isFocusable.mjs b/dist/private/utils/isFocusable.mjs +index d29c257f391589e5c06d8279f54b483864cb2dbc..f36a7ee287be99705b28d2f79073b97e78e28acc 100644 +--- a/dist/private/utils/isFocusable.mjs ++++ b/dist/private/utils/isFocusable.mjs +@@ -39,7 +39,7 @@ function $3b8b240c1bf84ab9$export$bebd5a1431fec25d(element) { + function $3b8b240c1bf84ab9$var$isInert(element) { + let node = element; + while(node != null){ +- if (node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; ++ if (node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement && node.inert) return true; + node = node.parentElement; + } + return false; diff --git a/AGENTS.md b/AGENTS.md index 55f3c0752f22..693c451c8628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This file is the canonical instruction source for coding agents. Files like `CLA Storybook is a large TypeScript monorepo. The git root is the repo root, the main code lives in `code/`, and build tooling lives in `scripts/`. The default branch is `next`. - **Base branch**: `next` (all PRs should target `next`, not `main`) -- **Node.js**: `22.22.1` (see `.nvmrc`) — supports `.ts` natively via type stripping (no loader needed) +- **Node.js**: `22.22.3` (see `.nvmrc`) — supports `.ts` natively via type stripping (no loader needed) - **Package Manager**: Yarn Berry - **Task orchestration**: NX plus the custom `yarn task` runner - **CI environment**: Linux and Windows @@ -242,12 +242,38 @@ When writing unit tests (utilities, hooks, non-React modules): - Test real behavior, not just syntax patterns - Use coverage when useful: `yarn vitest run --coverage ` - Mock external dependencies like file system access and loggers +- Use Node's path.resolve to wrap expected FS paths when writing path-related tests, so they work on Windows + +### Filesystem tests with `memfs` + +For unit tests that touch `node:fs` / `node:fs/promises`, use [`memfs`](https://github.com/streamich/memfs) instead of real temp directories or wholesale `node:fs` mocks: + +- Import `vol` from `memfs` and call `vol.reset()` in `beforeEach` +- Seed virtual files with `vol.fromNestedJSON({ '/absolute/path/file.json': '...' })` or memfs `writeFile` after redirecting spies +- Use `vi.mock('node:fs/promises', { spy: true })` and, in `beforeEach`, point `mkdir` / `writeFile` / `readFile` at `memfs.fs.promises` (see `code/core/src/shared/open-service/server.test.ts`) +- Assert disk state with `vol.toJSON()` when helpful + +Do **not** use `/tmp` paths or replace `node:fs/promises` with a full async factory mock unless a test file already standardizes on the spy redirect pattern above. + +### Globals in tests: never assign `globalThis.*` directly + +> [!IMPORTANT] +> Under no circumstances may a test mutate a global by assigning it directly (e.g. `globalThis.FEATURES = {...}`, `globalThis.window = ...`, `global.fetch = ...`). Direct assignment leaks across tests and files — Vitest does not restore it — so it silently changes behavior in unrelated tests and creates order-dependent flakiness. + +Use Vitest's global stubbing instead, which is tracked and restorable: + +- Set a global with `vi.stubGlobal('FEATURES', { experimentalDocgenServer: true })`. +- Restore in `afterEach(() => vi.unstubAllGlobals())` (or enable `unstubGlobals: true` in the Vitest config so it resets before each test automatically). +- For a value used by every test in a file, stub it in `beforeEach` and unstub in `afterEach`; for a one-off override, call `vi.stubGlobal` inside that single test. +- Never capture-and-restore by hand (`const original = globalThis.X; ... globalThis.X = original`); `vi.stubGlobal` + `vi.unstubAllGlobals()` does this correctly, including deleting keys that did not previously exist. + +This applies to all ambient globals, not just `FEATURES` (e.g. `window`, `document`, `navigator`, `fetch`, `IS_REACT_ACT_ENVIRONMENT`). ## Quality and Logging After changing files: -1. Format with `yarn fmt:write` (run from the repo root) +1. **Always** format with `yarn fmt:write`, run from the `code/` directory (`cd code && yarn fmt:write`), once you are done editing. The repo uses `oxfmt`, so hand-written formatting will frequently be wrong — do not skip this step. 2. Lint with `yarn --cwd code lint:js:cmd --fix` or `cd code && yarn lint:js:cmd ` 3. Run relevant tests before submitting a PR @@ -273,12 +299,12 @@ Avoid `console.log`, `console.warn`, and `console.error` unless the file is isol ## Environment Variables -| Variable | Purpose | -| ----------------------------- | --------------------------- | -| `IN_STORYBOOK_SANDBOX` | Set during sandbox creation | -| `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | -| `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | -| `DEBUG` | Enable debug logging | +| Variable | Purpose | +| ----------------------------- | ----------------------------------------------- | +| `IN_STORYBOOK_SANDBOX` | Set during sandbox creation | +| `STORYBOOK_DISABLE_TELEMETRY` | Disable telemetry | +| `STORYBOOK_TELEMETRY_DEBUG` | Log telemetry events | +| `DEBUG` | Enable debug logging | | `FIX_ON_COMMIT` | Force autofix for fmt & lint in pre-commit hook | ## Commands To Avoid @@ -288,6 +314,19 @@ Avoid `console.log`, `console.warn`, and `console.error` unless the file is isol These usually start long-running development servers and are the wrong default for agents. +## Code Authoring Principles + +These are recurring failure modes in agent-authored changes to this repo. Apply them when writing or reviewing code, not just when asked. + +- **Comments are maintenance docs, not an investigation transcript.** Explain *why* for the next maintainer. Do not commit internal ticket / acceptance-criteria codes (`AC-X2`, `Probe B`, `R6`), the narrative of how you figured something out, "verified byte-identical" provenance prose, or cross-file line references (`L125→L131`) — they are noise and they rot. One or two sentences of rationale beats a paragraph of evidence. +- **Verify environment assumptions empirically before encoding them.** If a design rests on "the bundler strips X" or "this metadata is empty here", prove it with a throwaway probe before building on it (and before writing it into a comment as fact). A 10-line experiment is cheaper than a wrong architecture. +- **Encode assumptions with static checks first.** If an assumption is expected to always hold, prefer making it impossible via TypeScript types and existing lint rules. When static checks are not practical, add a cheap runtime assertion close to the boundary so violations fail loudly at the source. +- **Avoid redundant tests already covered elsewhere.** Do not add tests for code patterns already guaranteed by TypeScript or linting, and do not duplicate coverage that already exists in Storybook `play` functions or Playwright tests. +- **Test contracts (including side effects), not private implementation details.** It is valid to assert side effects when they are part of the public contract. Avoid assertions about internals that are not part of an exported contract, user-visible DOM output, or externally observable behavior. +- **Bias toward broader coverage for security and migrations.** For security-sensitive code paths and legacy data migration logic, prefer handling more edge cases and documenting evidence for the chosen safeguards. Migration compatibility code should be explicitly version-scoped so it can be removed once the support window ends. +- **Prefer deletion and simplicity over speculative generality.** No abstraction, fallback, or "flexibility" for a consumer or scenario that does not exist in this codebase today. If a change adds many lines, check whether the right change removes them. +- **Don't commit accidental overrides to generated code.** Files like `code/core/src/manager/globals/exports.ts` are auto-generated, as stated in their JSDoc header. Only commit changes if they match changes you made on your PR, otherwise leave them untouched and flag flaky generated files in the PR description. + ## Maintenance Rules For Agents - Use this file as the canonical instruction source diff --git a/CHANGELOG.md b/CHANGELOG.md index 884e765a3d1a..e3c92b3fc7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,150 @@ +## 10.4.6 + +- CSF: Allow partial globals overrides in story and meta annotations - [#34985](https://github.com/storybookjs/storybook/pull/34985), thanks @TheSeydiCharyyev! +- Dependencies: Upgrade esbuild - [#35157](https://github.com/storybookjs/storybook/pull/35157), thanks @Kakadus! + +## 10.4.5 + +- Core: Rework AI checklist feature gate - [#35053](https://github.com/storybookjs/storybook/pull/35053), thanks @Sidnioulz! +- Preview: Stop mixed CSF3+4 stories getting core annotations injected twice - [#35094](https://github.com/storybookjs/storybook/pull/35094), thanks @JReinhold! + +## 10.4.4 + +- Telemetry: Add timeout to event-log POST to prevent build hang - [#35085](https://github.com/storybookjs/storybook/pull/35085), thanks @badams! + +## 10.4.3 + +- Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages - [#34496](https://github.com/storybookjs/storybook/pull/34496), thanks @NYCU-Chung! +- Core: Respect !dev tag on MDX docs in sidebar - [#35031](https://github.com/storybookjs/storybook/pull/35031), thanks @JReinhold! +- React: Add support for resolving subcomponents attached as properties of a parent component - [#34967](https://github.com/storybookjs/storybook/pull/34967), thanks @yatishgoel! +- UI: Prevent docs page scroll reset on HMR re-render - [#35021](https://github.com/storybookjs/storybook/pull/35021), thanks @LongTangGithub! + +## 10.4.2 + +- Bug: Fix Windows command resolution for non-Node package managers - [#33534](https://github.com/storybookjs/storybook/pull/33534), thanks @copilot-swe-agent! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CSF: Fix parsing of string literal export names - [#34901](https://github.com/storybookjs/storybook/pull/34901), thanks @shilman! +- Publish: Add npm provenance attestations - [#34936](https://github.com/storybookjs/storybook/pull/34936), thanks @copilot-swe-agent! + +## 10.4.1 + +- Angular: Detect model() signal outputs (type inference + compodoc autodocs + runtime binding) - [#34833](https://github.com/storybookjs/storybook/pull/34833), thanks @valentinpalkovic! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CLI: Run `npx expo install --fix` after init for Expo projects - [#34803](https://github.com/storybookjs/storybook/pull/34803), thanks @ndelangen! +- CLI: Support `peerDependencies` in framework detection for component libraries - [#34516](https://github.com/storybookjs/storybook/pull/34516), thanks @zhyd1997! +- Next.js: Add useLinkStatus mock to next/link export mock - [#34593](https://github.com/storybookjs/storybook/pull/34593), thanks @philwolstenholme! +- Vue3: Specify a specific version for non-dev dependency - [#34794](https://github.com/storybookjs/storybook/pull/34794), thanks @ScopeyNZ! + +## 10.4.0 + +> _AI-assisted setup, change-aware review, and stronger framework support_ + +Storybook 10.4 contains hundreds of fixes and improvements including: + +- 🤖 Agentic Setup: New CLI workflow for AI-assisted Storybook setup and onboarding +- 🔍 Change review: Sidebar filtering to highlight new, modified, and related stories based on git changes +- 🧭 Sidebar review tools: Status filtering, URL-persisted filters, and clearer review signals in the sidebar +- ⚛️ TanStack React: New `@storybook/tanstack-react` framework with routing and server function support +- 🧩 React MCP: Faster, more accurate component docgen powered by the TypeScript Language Server +- 📱 React Native: Zero config RN project initialization +- 🤝 Sharing: Easily publish and share your local Storybook with teammates, powered by Chromatic + + +List of all updates + +- A11y: Add aria-live announcements via @react-aria/live-announcer - [#33970](https://github.com/storybookjs/storybook/pull/33970), thanks @copilot-swe-agent! +- A11y: Improve boolean control contrast in forced colors mode - [#34204](https://github.com/storybookjs/storybook/pull/34204), thanks @anchmelev! +- Actions: Fix state mutation and keep newest actions when limit reached - [#34286](https://github.com/storybookjs/storybook/pull/34286), thanks @Sidnioulz! +- Addon-Docs: Add Reset story button to re-render stories in docs - [#34086](https://github.com/storybookjs/storybook/pull/34086), thanks @6810779s! +- Addon-Docs: Avoid rerendering static Source blocks - [#34206](https://github.com/storybookjs/storybook/pull/34206), thanks @anchmelev! +- Addon-Vitest: Use Vitest's provide-API for injecting values - [#34518](https://github.com/storybookjs/storybook/pull/34518), thanks @JReinhold! +- Agentic Setup: Add --extensive for an extra prompt - [#34730](https://github.com/storybookjs/storybook/pull/34730), thanks @Sidnioulz! +- Agentic Setup: Allow failed stories to persist - [#34717](https://github.com/storybookjs/storybook/pull/34717), thanks @Sidnioulz! +- Agentic Setup: Keep sample content if users want onboarding - [#34704](https://github.com/storybookjs/storybook/pull/34704), thanks @Sidnioulz! +- Agentic Setup: Rework ai-init-opt-in logic - [#34739](https://github.com/storybookjs/storybook/pull/34739), thanks @Sidnioulz! +- Angular: Use Story ID for renderer IDs (including standalone stories) - [#33982](https://github.com/storybookjs/storybook/pull/33982), thanks @ValentinFunk! +- Automigration: Move RN on-device addons to `deviceAddons` - [#34659](https://github.com/storybookjs/storybook/pull/34659), thanks @ndelangen! +- Builder-Vite: Add onModuleGraphChange method - [#34323](https://github.com/storybookjs/storybook/pull/34323), thanks @ghengeveld! +- CLI: Add automigrate check for 'storybook' package name conflict - [#34290](https://github.com/storybookjs/storybook/pull/34290), thanks @whdjh! +- CLI: Add react-vite to tanstack-react automigration - [#34718](https://github.com/storybookjs/storybook/pull/34718), thanks @huang-julien! +- CLI: Change mock event detection - [#34586](https://github.com/storybookjs/storybook/pull/34586), thanks @yannbf! +- CLI: Explicitly tell whether smoke tests passed or failed - [#34419](https://github.com/storybookjs/storybook/pull/34419), thanks @Sidnioulz! +- CLI: Fix Next.js Vite automigration corrupting configs already using `@storybook/nextjs-vite` - [#34249](https://github.com/storybookjs/storybook/pull/34249), thanks @nathanjessen! +- CLI: Fix agentic check - [#34678](https://github.com/storybookjs/storybook/pull/34678), thanks @yannbf! +- CLI: Handle minimumReleaseAge conflicts across package managers - [#34769](https://github.com/storybookjs/storybook/pull/34769), thanks @JReinhold! +- CLI: Improve package incompatibility detection and warning - [#34559](https://github.com/storybookjs/storybook/pull/34559), thanks @copilot-swe-agent! +- CLI: Improve self-healing scoring observability - [#34699](https://github.com/storybookjs/storybook/pull/34699), thanks @yannbf! +- CLI: Introduce Agentic Setup workflow - [#34297](https://github.com/storybookjs/storybook/pull/34297), thanks @yannbf! +- CLI: Remove extensive prompt option - [#34740](https://github.com/storybookjs/storybook/pull/34740), thanks @yannbf! +- CLI: Streamline Node.js version detection code - [#34440](https://github.com/storybookjs/storybook/pull/34440), thanks @Sidnioulz! +- Change-Detection: Set GIT_OPTIONAL_LOCKS=0 to avoid blocking commits - [#34726](https://github.com/storybookjs/storybook/pull/34726), thanks @valentinpalkovic! +- Cli: Set ai prompt to yes if yes flag for react-vite to tanstack migration - [#34743](https://github.com/storybookjs/storybook/pull/34743), thanks @huang-julien! +- Code: Fix inline code blocks inside links removing link affordance - [#33903](https://github.com/storybookjs/storybook/pull/33903), thanks @yatishgoel! +- Controls: Add maxPresetColors option to ColorControl - [#33998](https://github.com/storybookjs/storybook/pull/33998), thanks @mixelburg! +- Core: Add `ChangeDetectionService` and wire up builder-vite - [#34369](https://github.com/storybookjs/storybook/pull/34369), thanks @ghengeveld! +- Core: Add changeDetection feature flag - [#34314](https://github.com/storybookjs/storybook/pull/34314), thanks @valentinpalkovic! +- Core: Barrel-aware named import resolution for change detection - [#34675](https://github.com/storybookjs/storybook/pull/34675), thanks @valentinpalkovic! +- Core: Ensure process termination on SIGINT when telemetry is disabled - [#34585](https://github.com/storybookjs/storybook/pull/34585), thanks @ghengeveld! +- Core: Fix "Open In Editor" support for VSCode - [#34747](https://github.com/storybookjs/storybook/pull/34747), thanks @JReinhold! +- Core: Fix telemetry not handling canceling of prompts - [#34680](https://github.com/storybookjs/storybook/pull/34680), thanks @JReinhold! +- Core: Implement Git change detection - [#34420](https://github.com/storybookjs/storybook/pull/34420), thanks @ghengeveld! +- Core: Improve startup performance by deferring change detection initialization - [#34498](https://github.com/storybookjs/storybook/pull/34498), thanks @ghengeveld! +- Core: Normalize file paths in ChangeDetectionService and trace-changed for Windows support - [#34445](https://github.com/storybookjs/storybook/pull/34445), thanks @ghengeveld! +- Core: Quiet change-detection regex warning and swap clear icon - [#34758](https://github.com/storybookjs/storybook/pull/34758), thanks @valentinpalkovic! +- Core: Rename preview.ts to preview.tsx in generated projects - [#34396](https://github.com/storybookjs/storybook/pull/34396), thanks @Sidnioulz! +- Core: Show "new" status on newly added individual stories - [#34504](https://github.com/storybookjs/storybook/pull/34504), thanks @ghengeveld! +- Dependencies: Update `vite-plugin-storybook-nextjs` to ^3.2.4 - [#34280](https://github.com/storybookjs/storybook/pull/34280), thanks @k35o! +- Docs: Ensure unique control id attributes across multiple Controls blocks - [#34021](https://github.com/storybookjs/storybook/pull/34021), thanks @TheSeydiCharyyev! +- Fix ArgsTable borders not visible in Windows High Contrast Mode - [#34264](https://github.com/storybookjs/storybook/pull/34264), thanks @TheSeydiCharyyev! +- Fix: Add vite-plus vendored libraries version detection - [#34509](https://github.com/storybookjs/storybook/pull/34509), thanks @huang-julien! +- MDX: Replace `@storybook/docs-mdx` with inline implementation - [#34611](https://github.com/storybookjs/storybook/pull/34611), thanks @copilot-swe-agent! +- Maintenance: Add assertions outside step incorrectly nested in interactions panel - [#34296](https://github.com/storybookjs/storybook/pull/34296), thanks @majiayu000! +- Maintenance: Enhance ghost stories internal tests - [#34707](https://github.com/storybookjs/storybook/pull/34707), thanks @yannbf! +- Maintenance: Extract getBuilderOptions helper across framewo… - [#34260](https://github.com/storybookjs/storybook/pull/34260), thanks @alex-js-ltd! +- Maintenance: Extract parseFilterParam shared helper from tags and statuses modules - [#34436](https://github.com/storybookjs/storybook/pull/34436), thanks @mixelburg! +- Maintenance: Fix self healing payload - [#34782](https://github.com/storybookjs/storybook/pull/34782), thanks @yannbf! +- Maintenance: Remove dead-code copy of wrap-getAbsolutePath-utils - [#34168](https://github.com/storybookjs/storybook/pull/34168), thanks @mixelburg! +- Maintenance: Use errorToErrorLike in boot-test-runner for consistent stack deduplication - [#34385](https://github.com/storybookjs/storybook/pull/34385), thanks @mixelburg! +- Manager: URL-based tag filter state + filter-aware initial story selection - [#34283](https://github.com/storybookjs/storybook/pull/34283), thanks @valentinpalkovic! +- Nextjs: Handle node builtin webpack imports - [#34494](https://github.com/storybookjs/storybook/pull/34494), thanks @JReinhold! +- Onboarding: Fix checklist MDX instructions - [#33193](https://github.com/storybookjs/storybook/pull/33193), thanks @kylegach! +- Prompt: Run vitest fewer times, improve play functions - [#34651](https://github.com/storybookjs/storybook/pull/34651), thanks @yannbf! +- React-Docgen: Add tsconfig fallback chain and warning for monorepos - [#34353](https://github.com/storybookjs/storybook/pull/34353), thanks @viditkbhatnagar! +- React: Add component metadata extraction via Volar-style LanguageService - [#33914](https://github.com/storybookjs/storybook/pull/33914), thanks @kasperpeulen! +- React: Add subcomponents to component manifests - [#34428](https://github.com/storybookjs/storybook/pull/34428), thanks @kasperpeulen! +- ReactNative: Add Metro config AST codemod for init - [#34660](https://github.com/storybookjs/storybook/pull/34660), thanks @ndelangen! +- ReactNative: Add true entrypoint generation - [#34663](https://github.com/storybookjs/storybook/pull/34663), thanks @ndelangen! +- ReactNative: AppRegistry component name in template - [#34742](https://github.com/storybookjs/storybook/pull/34742), thanks @ndelangen! +- ReactNative: New init setup - [#34665](https://github.com/storybookjs/storybook/pull/34665), thanks @ndelangen! +- Refactor: Extract shared `PseudoStateGrid` component in pseudo-states stories - [#34334](https://github.com/storybookjs/storybook/pull/34334), thanks @copilot-swe-agent! +- Security: Makes sure `serialize-javascript` is at latest version - [#34034](https://github.com/storybookjs/storybook/pull/34034), thanks @50bbx! +- Sidebar: Add dual-slot status icons for change detection and test results - [#34346](https://github.com/storybookjs/storybook/pull/34346), thanks @valentinpalkovic! +- Sidebar: Add status-based filtering with refactored status architecture - [#34339](https://github.com/storybookjs/storybook/pull/34339), thanks @valentinpalkovic! +- Sidebar: Fix clear filter button not refreshing story list - [#34737](https://github.com/storybookjs/storybook/pull/34737), thanks @valentinpalkovic! +- Sidebar: Fix clear status button to only clear test statuses - [#34478](https://github.com/storybookjs/storybook/pull/34478), thanks @valentinpalkovic! +- Sidebar: Show same status icon at story and group level - [#34702](https://github.com/storybookjs/storybook/pull/34702), thanks @valentinpalkovic! +- Sidebar: Soften change-detection signals + add Review CTA - [#34701](https://github.com/storybookjs/storybook/pull/34701), thanks @valentinpalkovic! +- StatusValue: Add 'status-value:' - [#34305](https://github.com/storybookjs/storybook/pull/34305), thanks @valentinpalkovic! +- Svelte: Fix Vite 8 + Vitest breaking rolldown deps scanner - [#34783](https://github.com/storybookjs/storybook/pull/34783), thanks @JReinhold! +- Tanstack: Add `@storybook/tanstack-react` package - [#34403](https://github.com/storybookjs/storybook/pull/34403), thanks @huang-julien! +- Tanstack: Optimize tanstack react-store - [#34731](https://github.com/storybookjs/storybook/pull/34731), thanks @huang-julien! +- Tanstack: Treeshake top-level unused functions - [#34760](https://github.com/storybookjs/storybook/pull/34760), thanks @huang-julien! +- Telemetry: Add sidebar filter telemetry for change detection - [#34533](https://github.com/storybookjs/storybook/pull/34533), thanks @valentinpalkovic! +- Telemetry: Centralize disable logic with module-level flag - [#34485](https://github.com/storybookjs/storybook/pull/34485), thanks @valentinpalkovic! +- Telemetry: Fix delayed init events - [#34670](https://github.com/storybookjs/storybook/pull/34670), thanks @JReinhold! +- Telemetry: Refactor init tracking - [#34629](https://github.com/storybookjs/storybook/pull/34629), thanks @Programer1804! +- UI: Add Share section to onboarding checklist and redesign share tool - [#34413](https://github.com/storybookjs/storybook/pull/34413), thanks @valentinpalkovic! +- UI: Ensure Controls panel can scroll horizontally for now - [#34248](https://github.com/storybookjs/storybook/pull/34248), thanks @Sidnioulz! +- UI: Fix global shortcuts not showing region focus indicator - [#34201](https://github.com/storybookjs/storybook/pull/34201), thanks @Sidnioulz! +- UI: Fix mobile navigation when renderLabel returns a React node - [#34262](https://github.com/storybookjs/storybook/pull/34262), thanks @Nathan54Villaume! +- UI: Fix showing and hiding copy prompt in the correct scenarios - [#34706](https://github.com/storybookjs/storybook/pull/34706), thanks @yannbf! +- UI: Improve interactions panel accessibility - [#34110](https://github.com/storybookjs/storybook/pull/34110), thanks @anchmelev! +- Vite: Use vite hook filter for performance improvements - [#34022](https://github.com/storybookjs/storybook/pull/34022), thanks @huang-julien! +- Vitest: Fix agent detection breaking runs - [#34681](https://github.com/storybookjs/storybook/pull/34681), thanks @JReinhold! +- Vue3: Clear stale args/globals when nextArgs is empty in updateArgs - [#34409](https://github.com/storybookjs/storybook/pull/34409), thanks @whdjh! + + + ## 10.3.6 - ESLint: Update deprecated @storybook/test reference to storybook/test - [#34430](https://github.com/storybookjs/storybook/pull/34430), thanks @venkat22022202! diff --git a/CHANGELOG.prerelease.md b/CHANGELOG.prerelease.md index 0b0cc2349d60..ac8d75733c9a 100644 --- a/CHANGELOG.prerelease.md +++ b/CHANGELOG.prerelease.md @@ -1,3 +1,208 @@ +## 10.5.0-beta.1 + +- Core: Add experimentalReview feature flag and make the features type augmentable - [#35379](https://github.com/storybookjs/storybook/pull/35379), thanks @yannbf! + +## 10.5.0-beta.0 + + +## 10.5.0-alpha.11 + +- Addon-vitest: Add an initialGlobals option to pin a project's globals - [#35226](https://github.com/storybookjs/storybook/pull/35226), thanks @lifeiscontent! +- Core: Add ai-review observability - [#35300](https://github.com/storybookjs/storybook/pull/35300), thanks @yannbf! +- Core: Allow vite-plus 0.2.x in the optional peer range - [#35221](https://github.com/storybookjs/storybook/pull/35221), thanks @lifeiscontent! +- Manager: Restructure review feature state, layout integration, and thumbnail pipeline - [#35351](https://github.com/storybookjs/storybook/pull/35351), thanks @ghengeveld! +- Preview: Restore iframe scrolling after resizing the layout - [#35310](https://github.com/storybookjs/storybook/pull/35310), thanks @yatishgoel! + +## 10.5.0-alpha.10 + +- A11y: Handle lang attribute throughout preview - [#35321](https://github.com/storybookjs/storybook/pull/35321), thanks @Sidnioulz! +- A11y: Surface required args and keyboard-reachable Setup controls in ArgsTable - [#35306](https://github.com/storybookjs/storybook/pull/35306), thanks @Sidnioulz! +- Addon-Vitest: Subscribe for run completion before triggering it - [#35291](https://github.com/storybookjs/storybook/pull/35291), thanks @tsushanth! +- Core: Compose core annotations before preview beforeAll - [#35323](https://github.com/storybookjs/storybook/pull/35323), thanks @JReinhold! +- Docgen: Run experimentalDocgenServer extraction in a worker thread - [#35324](https://github.com/storybookjs/storybook/pull/35324), thanks @JReinhold! +- DocgenServer: Fix OOM by recycling the shared TS program - [#35305](https://github.com/storybookjs/storybook/pull/35305), thanks @ndelangen! +- Next: Call link onClick before preventing default - [#35311](https://github.com/storybookjs/storybook/pull/35311), thanks @yatishgoel! +- Review: Dynamic thumbnail scaling based on content dimensions - [#35282](https://github.com/storybookjs/storybook/pull/35282), thanks @ghengeveld! +- Review: Improve review thumbnail scaling and loading UX - [#35335](https://github.com/storybookjs/storybook/pull/35335), thanks @ghengeveld! +- Review: Notify on review arrival instead of auto-navigating - [#35276](https://github.com/storybookjs/storybook/pull/35276), thanks @ghengeveld! +- Review: Simplify layout handling - [#35322](https://github.com/storybookjs/storybook/pull/35322), thanks @ghengeveld! +- UI: Hide onboarding menu with feature flag - [#35299](https://github.com/storybookjs/storybook/pull/35299), thanks @Sidnioulz! +- Vue: Fix Unicode Default Values In Docs - [#34909](https://github.com/storybookjs/storybook/pull/34909), thanks @Arunsiva003! + +## 10.5.0-alpha.9 + +- Addon Vitest: Avoid erroring out on benign Win process exits - [#35287](https://github.com/storybookjs/storybook/pull/35287), thanks @Sidnioulz! +- Core: Introduce Agentic Review feature - [#34837](https://github.com/storybookjs/storybook/pull/34837), thanks @yannbf! +- Core: Recognize addon-mcp registered via getAbsolutePath() in runtime instance registry - [#35286](https://github.com/storybookjs/storybook/pull/35286), thanks @yannbf! +- ESLint Plugin: Avoid ESLint Unstable API Load - [#35269](https://github.com/storybookjs/storybook/pull/35269), thanks @pupuking723! +- Index: Remove `hasActiveFilters` check - [#35151](https://github.com/storybookjs/storybook/pull/35151), thanks @mrginglymus! + +## 10.5.0-alpha.8 + +- Angular: Add versioned `@types/node` to packages installed during `storybook init` - [#34192](https://github.com/storybookjs/storybook/pull/34192), thanks @copilot-swe-agent! +- Angular: Introduce @storybook/angular-vite package - [#34202](https://github.com/storybookjs/storybook/pull/34202), thanks @brandonroberts! +- Angular: Use future-proof API for component reflection - [#35228](https://github.com/storybookjs/storybook/pull/35228), thanks @Sidnioulz! +- Babel: Remove bugfixes from preset-env in v8 - [#35266](https://github.com/storybookjs/storybook/pull/35266), thanks @Sidnioulz! +- Builder-Vite: Fix empty external globals imports - [#34348](https://github.com/storybookjs/storybook/pull/34348), thanks @raashish1601! +- CLI: Exit process after successful build - [#34735](https://github.com/storybookjs/storybook/pull/34735), thanks @torleifhalseth! +- CLI: Fix upgrade crash on Storybook latest version check - [#35156](https://github.com/storybookjs/storybook/pull/35156), thanks @yatishgoel! +- CLI: Load Storybook AI help from preset metadata - [#35212](https://github.com/storybookjs/storybook/pull/35212), thanks @kasperpeulen! +- CLI: Prefer agent-matched Storybook instances - [#35235](https://github.com/storybookjs/storybook/pull/35235), thanks @kasperpeulen! +- CLI: Show MCP workflow instructions in AI help - [#35164](https://github.com/storybookjs/storybook/pull/35164), thanks @kasperpeulen! +- CLI: Support Claude preview autoPort - [#35259](https://github.com/storybookjs/storybook/pull/35259), thanks @kasperpeulen! +- Cli: Install MCP when upgrade is ran by agent - [#35215](https://github.com/storybookjs/storybook/pull/35215), thanks @huang-julien! +- Controls: Prevent AbortError when rapidly resetting - [#34412](https://github.com/storybookjs/storybook/pull/34412), thanks @whdjh! +- Controls: Prevent the save bar from covering the last control - [#35136](https://github.com/storybookjs/storybook/pull/35136), thanks @TheSeydiCharyyev! +- Core: Fix npm dependency detection - [#35083](https://github.com/storybookjs/storybook/pull/35083), thanks @fallintoplace! +- Core: Flush preview-api hook effects in portable stories - [#35224](https://github.com/storybookjs/storybook/pull/35224), thanks @lifeiscontent! +- Core: Include chromatic packages in ecosystem identifier - [#35170](https://github.com/storybookjs/storybook/pull/35170), thanks @yannbf! +- Core: Use UndoIcon for Review-changes clear button - [#34767](https://github.com/storybookjs/storybook/pull/34767), thanks @valentinpalkovic! +- Docgen: Fix inferred argTypes from initial args overriding docgen and custom argTypes in experimentalDocgenServer mode - [#35196](https://github.com/storybookjs/storybook/pull/35196), thanks @JReinhold! +- Docs: Add shallow MDX ref manifests for experimentalDocgenServer - [#35189](https://github.com/storybookjs/storybook/pull/35189), thanks @JReinhold! +- Docs: Improve DocsParameters types - [#35190](https://github.com/storybookjs/storybook/pull/35190), thanks @mrginglymus! +- Docs: Restore React 16/17 support in useServiceDocgen - [#35179](https://github.com/storybookjs/storybook/pull/35179), thanks @JReinhold! +- Manager: Fix crash when toggling addon panel on mobile (LandmarkManager TypeError) - [#34790](https://github.com/storybookjs/storybook/pull/34790), thanks @valentinpalkovic! +- Manager: Fix race condition in `experimental_setFilter` - [#35194](https://github.com/storybookjs/storybook/pull/35194), thanks @mrginglymus! +- Measure & Outline: Honor the `disable` parameter - [#35150](https://github.com/storybookjs/storybook/pull/35150), thanks @yatishgoel! +- ModuleGraph: Stop bumping the graph revision on story-index invalidation - [#35178](https://github.com/storybookjs/storybook/pull/35178), thanks @JReinhold! +- NextJS: Support `as` prop in next/link mock - [#35148](https://github.com/storybookjs/storybook/pull/35148), thanks @yatishgoel! +- Open Service: Split story docs from docgen service - [#35169](https://github.com/storybookjs/storybook/pull/35169), thanks @JReinhold! +- Open-Service: Introduce query states (loading/error/success) - [#35214](https://github.com/storybookjs/storybook/pull/35214), thanks @JReinhold! +- Open-Service: Type getService for core services per runtime - [#35242](https://github.com/storybookjs/storybook/pull/35242), thanks @JReinhold! +- Preview: Preserve primitive args for non-ReactNode other types - [#35088](https://github.com/storybookjs/storybook/pull/35088), thanks @TheSeydiCharyyev! +- PseudoStates: Preserve URL globals on story change - [#35211](https://github.com/storybookjs/storybook/pull/35211), thanks @Sidnioulz! +- React Native: Remove babel deps from RN setup (not needed) - [#35243](https://github.com/storybookjs/storybook/pull/35243), thanks @dannyhw! +- React: Align react-docgen versions - [#35271](https://github.com/storybookjs/storybook/pull/35271), thanks @Sidnioulz! +- UI: Fix args not preserved in isolation mode - [#35055](https://github.com/storybookjs/storybook/pull/35055), thanks @sijie-Z! +- Vue: Use AST to inject __docgenInfo correctly - [#35201](https://github.com/storybookjs/storybook/pull/35201), thanks @Sidnioulz! + +## 10.5.0-alpha.7 + +- CLI: Add `storybook ai ` MCP passthrough behind `STORYBOOK_FEATURE_AI_CLI` - [#35125](https://github.com/storybookjs/storybook/pull/35125), thanks @kasperpeulen! +- CLI: Add telemetry for the `storybook ai ` passthrough - [#35138](https://github.com/storybookjs/storybook/pull/35138), thanks @kasperpeulen! +- CLI: Bundle the `ai` command in core so it never downloads `@storybook/cli` - [#35147](https://github.com/storybookjs/storybook/pull/35147), thanks @kasperpeulen! +- CSF: Allow partial globals overrides in story and meta annotations - [#34985](https://github.com/storybookjs/storybook/pull/34985), thanks @TheSeydiCharyyev! +- Dependencies: Upgrade esbuild - [#35157](https://github.com/storybookjs/storybook/pull/35157), thanks @Kakadus! +- Docgen: Register service runtime, payload argTypes - [#35108](https://github.com/storybookjs/storybook/pull/35108), thanks @JReinhold! +- Docs: Route ArgTypes and Controls through docgen service when flag enabled - [#35109](https://github.com/storybookjs/storybook/pull/35109), thanks @JReinhold! +- Open Service: Fix docgen hot update controls refresh - [#35158](https://github.com/storybookjs/storybook/pull/35158), thanks @JReinhold! +- Open Service: Load static query snapshots in browser builds - [#35154](https://github.com/storybookjs/storybook/pull/35154), thanks @JReinhold! +- Open Service: Mark module-graph engine commands as internal - [#35144](https://github.com/storybookjs/storybook/pull/35144), thanks @JReinhold! +- Telemetry: Preserve state machine when the module loads more than once - [#35140](https://github.com/storybookjs/storybook/pull/35140), thanks @kasperpeulen! +- Viewport: Warn when legacy `defaultViewport` parameter is used - [#35087](https://github.com/storybookjs/storybook/pull/35087), thanks @yatishgoel! + +## 10.5.0-alpha.6 + +- Controls: Guard normalizeOptions against array labels and prototype chain lookups - [#34664](https://github.com/storybookjs/storybook/pull/34664), thanks @creazyfrog! +- Core: Add ref-based components manifest index backed by docgen open service - [#35063](https://github.com/storybookjs/storybook/pull/35063), thanks @JReinhold! +- Core: Fix resolveImport TSX fallback - [#34815](https://github.com/storybookjs/storybook/pull/34815), thanks @cyphercodes! +- Core: Rework AI checklist feature gate - [#35053](https://github.com/storybookjs/storybook/pull/35053), thanks @Sidnioulz! +- Docs: Deprecate ExternalDocs - [#35074](https://github.com/storybookjs/storybook/pull/35074), thanks @Sidnioulz! +- Interactions: Prevent debugger controls from stealing focus - [#35073](https://github.com/storybookjs/storybook/pull/35073), thanks @BrenoSI03! +- Manager: Keep local preview iframe on host URL when a ref story loads first - [#35078](https://github.com/storybookjs/storybook/pull/35078), thanks @TheSeydiCharyyev! +- Module Graph: Refactor from StoryDependencyService to open service-based ModuleGraphService - [#35048](https://github.com/storybookjs/storybook/pull/35048), thanks @JReinhold! +- Open Service: Add `internal` property to control visibility - [#35057](https://github.com/storybookjs/storybook/pull/35057), thanks @JReinhold! +- Open Service: Add sync between server, manager and preview - [#35017](https://github.com/storybookjs/storybook/pull/35017), thanks @ndelangen! +- Open Service: Call remote commands in load functions - [#35106](https://github.com/storybookjs/storybook/pull/35106), thanks @JReinhold! +- Preview: Stop mixed CSF3+4 stories getting core annotations injected twice - [#35094](https://github.com/storybookjs/storybook/pull/35094), thanks @JReinhold! +- React: Fix subcomponent prop extraction without JSX usage - [#35107](https://github.com/storybookjs/storybook/pull/35107), thanks @JReinhold! +- Telemetry: Add timeout to event-log POST to prevent build hang - [#35085](https://github.com/storybookjs/storybook/pull/35085), thanks @badams! +- Viewport: Highlight toolbar button when custom viewport is active - [#35075](https://github.com/storybookjs/storybook/pull/35075), thanks @derinbarutcu17! +- Webpack: Gate lazy-compilation import pipeline on webpack version - [#34931](https://github.com/storybookjs/storybook/pull/34931), thanks @copilot-swe-agent! + +## 10.5.0-alpha.5 + +- Addon Docs: DocsContent not filling available width when TOC is enabled - [#35043](https://github.com/storybookjs/storybook/pull/35043), thanks @k-utsumi! +- Addon Docs: Fix Primary and Controls blocks not rendering in custom MDX pages - [#34496](https://github.com/storybookjs/storybook/pull/34496), thanks @NYCU-Chung! +- CSF: Fix Canvas and userEvent types under Yarn PnP - [#34986](https://github.com/storybookjs/storybook/pull/34986), thanks @yatishgoel! +- Controls: Load controls for stories from a composed ref - [#35050](https://github.com/storybookjs/storybook/pull/35050), thanks @TheSeydiCharyyev! +- Core: Expose backgrounds types from public subpath - [#35044](https://github.com/storybookjs/storybook/pull/35044), thanks @ShaharAviram1! +- Core: Respect !dev tag on MDX docs in sidebar - [#35031](https://github.com/storybookjs/storybook/pull/35031), thanks @JReinhold! +- Docgen: Add mock open service - [#34954](https://github.com/storybookjs/storybook/pull/34954), thanks @JReinhold! +- Docgen: Wire up react-component-meta with DocgenService - [#34963](https://github.com/storybookjs/storybook/pull/34963), thanks @JReinhold! +- Docs: Fix Controls losing focus when subcomponents are defined - [#35018](https://github.com/storybookjs/storybook/pull/35018), thanks @cssinate! +- Open Service: Fix reactivity on deep signals, fire subscribers on load dependencies - [#34979](https://github.com/storybookjs/storybook/pull/34979), thanks @JReinhold! +- Preview: Use width/height instead of min-* in CanvasWrap - [#35056](https://github.com/storybookjs/storybook/pull/35056), thanks @DevLikhith5! +- Pseudo States: Keep combinator broad pseudo selectors valid - [#35060](https://github.com/storybookjs/storybook/pull/35060), thanks @mturac! +- React: Add support for resolving subcomponents attached as properties of a parent component - [#34967](https://github.com/storybookjs/storybook/pull/34967), thanks @yatishgoel! +- TanStack: Normalize route-group handling in createRoute mock - [#34948](https://github.com/storybookjs/storybook/pull/34948), thanks @copilot-swe-agent! +- UI: Prevent docs page scroll reset on HMR re-render - [#35021](https://github.com/storybookjs/storybook/pull/35021), thanks @LongTangGithub! + +## 10.5.0-alpha.4 + +- Addon Vitest: Fix dynamic import failure with Vitest 3 - [#34927](https://github.com/storybookjs/storybook/pull/34927), thanks @Sidnioulz! +- Bug: Fix Windows command resolution for non-Node package managers - [#33534](https://github.com/storybookjs/storybook/pull/33534), thanks @copilot-swe-agent! +- CSF Next: Propagate skip tags to .test children - [#34964](https://github.com/storybookjs/storybook/pull/34964), thanks @kwonoj! +- Controls: Improve ArgsTable empty state guidance - [#34857](https://github.com/storybookjs/storybook/pull/34857), thanks @Aniketiitk21! +- Core: Add missing export to globals - [#34929](https://github.com/storybookjs/storybook/pull/34929), thanks @Sidnioulz! +- Core: Clean stale runtime instance records - [#35023](https://github.com/storybookjs/storybook/pull/35023), thanks @kasperpeulen! +- Core: Extract StoryDependencyGraphService from ChangeDetectionService - [#35009](https://github.com/storybookjs/storybook/pull/35009), thanks @valentinpalkovic! +- Docs: Prevent heading anchor cutoff on docs pages - [#34945](https://github.com/storybookjs/storybook/pull/34945), thanks @copilot-swe-agent! +- Manager: Fix layout.showPanel config - [#34777](https://github.com/storybookjs/storybook/pull/34777), thanks @kalinco-glitch! +- Open Service: Sync queries, load/loaded() API, strict reader handlers - [#34932](https://github.com/storybookjs/storybook/pull/34932), thanks @JReinhold! +- Open-Service: Add schema-driven service runtime - [#34860](https://github.com/storybookjs/storybook/pull/34860), thanks @JReinhold! +- Open-service: Implement service registration on the server (attempt 2) - [#34961](https://github.com/storybookjs/storybook/pull/34961), thanks @JReinhold! +- Open-service: Implement service registration on the server - [#34875](https://github.com/storybookjs/storybook/pull/34875), thanks @JReinhold! +- Preview: Preserve @ts-expect-error in web-component and vue preview - [#34839](https://github.com/storybookjs/storybook/pull/34839), thanks @brentswisher! +- Publish: Add npm provenance attestations - [#34936](https://github.com/storybookjs/storybook/pull/34936), thanks @copilot-swe-agent! +- React: Render boolean props set to false in source snippets - [#34968](https://github.com/storybookjs/storybook/pull/34968), thanks @valentinpalkovic! +- Tanstack: Export `TanStackPreview` from `@storybook/tanstack-react` to unblock CSF Next typing - [#34949](https://github.com/storybookjs/storybook/pull/34949), thanks @copilot-swe-agent! +- Tanstack: Remove Outlet mock - [#35010](https://github.com/storybookjs/storybook/pull/35010), thanks @huang-julien! +- Tanstack: Supply id OR path when using RouteOptions for route mock - [#34950](https://github.com/storybookjs/storybook/pull/34950), thanks @copilot-swe-agent! +- Vue: Ensure vue-component-meta runs in post - [#34976](https://github.com/storybookjs/storybook/pull/34976), thanks @Sidnioulz! + +## 10.5.0-alpha.3 + +- A11y-Addon: Preserve disabled a11y rules with runOnly - [#34649](https://github.com/storybookjs/storybook/pull/34649), thanks @cyphercodes! +- Add an optional TypeScript peer to react-vite - [#34627](https://github.com/storybookjs/storybook/pull/34627), thanks @wojtekmaj! +- Addon-Docs: Resolve CSF4 module exports without a default export - [#34834](https://github.com/storybookjs/storybook/pull/34834), thanks @TheSeydiCharyyev! +- Addon-Docs: Resolve providerImportSource to a path instead of a file:// URL - [#34841](https://github.com/storybookjs/storybook/pull/34841), thanks @TheSeydiCharyyev! +- Build: Upgrade type-fest to latest version 5.6.0 - [#34791](https://github.com/storybookjs/storybook/pull/34791), thanks @tobiasdiez! +- CLI: Respect BROWSER and BROWSER_ARGS - [#34513](https://github.com/storybookjs/storybook/pull/34513), thanks @ianzone! +- CSF-Next: Add tags type support for - [#34819](https://github.com/storybookjs/storybook/pull/34819), thanks @unional! +- CSF: Fix parsing of string literal export names - [#34901](https://github.com/storybookjs/storybook/pull/34901), thanks @shilman! +- Controls: Add label to Object JSON control - [#34766](https://github.com/storybookjs/storybook/pull/34766), thanks @Jaksenc! +- Core: Add runtime instance registry - [#34863](https://github.com/storybookjs/storybook/pull/34863), thanks @kasperpeulen! +- Core: Categorize UniversalStore follower timeout error - [#34592](https://github.com/storybookjs/storybook/pull/34592), thanks @justismailmemon! +- Core: Fix EEXIST race condition in static file copying during build - [#34499](https://github.com/storybookjs/storybook/pull/34499), thanks @flt3150sk! +- Core: Improve ActionBar focus indicator in high contrast mode - [#34779](https://github.com/storybookjs/storybook/pull/34779), thanks @TheSeydiCharyyev! +- Core: Incorrect package json handling - [#34515](https://github.com/storybookjs/storybook/pull/34515), thanks @lino-levan! +- Maintenance: Centralize supported file extension lists - [#34844](https://github.com/storybookjs/storybook/pull/34844), thanks @valentinpalkovic! +- UI: Allow manager-head favicon override - [#34809](https://github.com/storybookjs/storybook/pull/34809), thanks @MukundaKatta! + +## 10.5.0-alpha.2 + +- A11y: Fix MDX heading anchors not keyboard accessible - [#34368](https://github.com/storybookjs/storybook/pull/34368), thanks @TheSeydiCharyyev! +- Angular: Detect model() signal outputs (type inference + compodoc autodocs + runtime binding) - [#34833](https://github.com/storybookjs/storybook/pull/34833), thanks @valentinpalkovic! +- CLI: Support `peerDependencies` in framework detection for component libraries - [#34516](https://github.com/storybookjs/storybook/pull/34516), thanks @zhyd1997! +- Docs: Add ariaLabel support to ActionItem interface - [#34749](https://github.com/storybookjs/storybook/pull/34749), thanks @TheSeydiCharyyev! +- Docs: Scope control input ids to each block instance - [#34793](https://github.com/storybookjs/storybook/pull/34793), thanks @TheSeydiCharyyev! +- Docs: Support explicit id prop on for standalone MDX - [#34808](https://github.com/storybookjs/storybook/pull/34808), thanks @TheSeydiCharyyev! +- Maintenance: Replace `resolve` and `resolve.exports` with `oxc-resolver` - [#34692](https://github.com/storybookjs/storybook/pull/34692), thanks @valentinpalkovic! +- Next.js-Vite: Bump vite-plugin-storybook-nextjs to ^3.3.0 - [#34838](https://github.com/storybookjs/storybook/pull/34838), thanks @yatishgoel! +- Vitest: Reset playwright cursor position to avoid hover bug - [#34765](https://github.com/storybookjs/storybook/pull/34765), thanks @Sidnioulz! +- Vue3: Specify a specific version for non-dev dependency - [#34794](https://github.com/storybookjs/storybook/pull/34794), thanks @ScopeyNZ! + +## 10.5.0-alpha.1 + +- Angular: Fix custom paths for stats.json on angular - [#34551](https://github.com/storybookjs/storybook/pull/34551), thanks @mrginglymus! +- Builder-Vite: Support configLoader via builder options - [#34080](https://github.com/storybookjs/storybook/pull/34080), thanks @holvi-sebastian! +- CLI: Run `npx expo install --fix` after init for Expo projects - [#34803](https://github.com/storybookjs/storybook/pull/34803), thanks @ndelangen! +- Core: Ignore story-like directories in indexer - [#34806](https://github.com/storybookjs/storybook/pull/34806), thanks @MukundaKatta! +- Next.js Vite: Add Link mock with useLinkStatus to nextjs-vite framework - [#34736](https://github.com/storybookjs/storybook/pull/34736), thanks @yatishgoel! +- Next.js: Add useLinkStatus mock to next/link export mock - [#34593](https://github.com/storybookjs/storybook/pull/34593), thanks @philwolstenholme! +- Test: Move @testing-library/dom to dependencies - [#34604](https://github.com/storybookjs/storybook/pull/34604), thanks @XionWCFM! + +## 10.5.0-alpha.0 + + +## 10.4.0-beta.0 + +- CLI: Handle minimumReleaseAge conflicts across package managers - [#34769](https://github.com/storybookjs/storybook/pull/34769), thanks @JReinhold! +- Core: Fix telemetry not handling canceling of prompts - [#34680](https://github.com/storybookjs/storybook/pull/34680), thanks @JReinhold! +- Maintenance: Fix self healing payload - [#34782](https://github.com/storybookjs/storybook/pull/34782), thanks @yannbf! +- Svelte: Fix Vite 8 + Vitest breaking rolldown deps scanner - [#34783](https://github.com/storybookjs/storybook/pull/34783), thanks @JReinhold! + ## 10.4.0-alpha.19 - Agentic Setup: Add --extensive for an extra prompt - [#34730](https://github.com/storybookjs/storybook/pull/34730), thanks @Sidnioulz! diff --git a/CODEOWNERS b/CODEOWNERS index 5ed2da00b6f4..4ed754fbc2e5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -62,4 +62,4 @@ # /code/renderers/web-components/ @kasperpeulen @JReinhold # E2E -# /code/e2e-tests/ @yannbf @valentinpalkovic +# /code/e2e-sandbox/ @yannbf @valentinpalkovic diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd2886782808..593cb8d23b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,8 @@ Here's a highlight of notable directories and files: │ ├── builders │ ├── chromatic.config.json │ ├── core # Core package for UI and API of Storybook -│ ├── e2e-tests +│ ├── e2e-internal # Playwright e2e tests for the internal Storybook UI +│ ├── e2e-sandbox # Playwright e2e tests for generated sandboxes │ ├── frameworks # Different framework-bundler versions of Storybook │ ├── lib # CLI and plugins │ ├── node_modules diff --git a/MIGRATION.md b/MIGRATION.md index 6dffa77bd1f3..4f746e55026c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,8 +1,10 @@ Migration +- [From version 10.4.0 to 10.5.0](#from-version-1040-to-1050) + - [ExternalDocs and ExternalDocsContainer are deprecated](#externaldocs-and-externaldocscontainer-are-deprecated) - [From version 10.3.0 to 10.4.0](#from-version-1030-to-1040) - [React Native: on-device addons moved to `deviceAddons`](#react-native-on-device-addons-moved-to-deviceaddons) - - [TanStack React: migrate from `@storybook/react-vite` to `@storybook/tanstack-react`](#tanstack-router-projects-migrate-from-storybookreact-vite-to-storybooktanstack-react) + - [TanStack Router projects: migrate from `@storybook/react-vite` to `@storybook/tanstack-react`](#tanstack-router-projects-migrate-from-storybookreact-vite-to-storybooktanstack-react) - [From version 10.0.0 to 10.1.0](#from-version-1000-to-1010) - [API and Component Changes](#api-and-component-changes) - [Button Component API Changes](#button-component-api-changes) @@ -523,6 +525,14 @@ - [Packages renaming](#packages-renaming) - [Deprecated embedded addons](#deprecated-embedded-addons) +## From version 10.4.0 to 10.5.0 + +### ExternalDocs and ExternalDocsContainer are deprecated + +The `ExternalDocs` and `ExternalDocsContainer` components from `@storybook/addon-docs` are deprecated and will be removed in Storybook 11. These components allowed rendering Storybook docs pages outside of the Storybook UI, but the approach was never stabilized and is redundant with other embedding options. + +If you are currently using `ExternalDocs` or `ExternalDocsContainer`, please open an issue describing your use case so the team can consider alternative solutions. + ## From version 10.3.0 to 10.4.0 ### React Native: on-device addons moved to `deviceAddons` diff --git a/code/.eslintrc.js b/code/.eslintrc.js index 03e449456d27..5900708a2b05 100644 --- a/code/.eslintrc.js +++ b/code/.eslintrc.js @@ -201,7 +201,7 @@ module.exports = { }, }, { - files: ['./e2e-tests/*.ts'], + files: ['./e2e-sandbox/*.ts', './e2e-internal/*.ts'], extends: ['plugin:playwright/recommended'], rules: { 'playwright/no-skipped-test': [ diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index c1e5d725acdc..89ac7e4a1a08 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -2,8 +2,10 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineMain } from '@storybook/react-vite/node'; +import type { Options } from 'storybook/internal/types'; import react from '@vitejs/plugin-react'; +import type { InlineConfig } from 'vite'; import { BROWSER_TARGETS } from '../core/src/shared/constants/environments-support.ts'; @@ -12,6 +14,7 @@ const currentDirPath = dirname(currentFilePath); const componentsPath = join(currentDirPath, '../core/src/components/index.ts'); const managerApiPath = join(currentDirPath, '../core/src/manager-api/index.mock.ts'); +const previewApiPath = join(currentDirPath, '../core/src/preview-api/index.ts'); const themingCreatePath = join(currentDirPath, '../core/src/theming/create.ts'); const themingPath = join(currentDirPath, '../core/src/theming/index.ts'); const imageContextPath = join(currentDirPath, '../frameworks/nextjs/src/image-context.ts'); @@ -35,6 +38,10 @@ const config = defineMain({ directory: '../core/src/preview', titlePrefix: 'preview', }, + { + directory: '../core/src/shared', + titlePrefix: 'core/shared', + }, { directory: '../core/src/components/brand', titlePrefix: 'brand', @@ -118,6 +125,7 @@ const config = defineMain({ '@storybook/addon-mcp', 'storybook-addon-pseudo-states', '@chromatic-com/storybook', + './services-preset.ts', ], previewAnnotations: [ './core/template/stories/preview.ts', @@ -149,10 +157,15 @@ const config = defineMain({ features: { developmentModeForBuild: true, experimentalTestSyntax: true, + // Disabled by default for production builds; the internal dev server enables it via + // STORYBOOK_EXPERIMENTAL_DOCGEN_SERVER so hot-update e2e covers the open-service path. + experimentalDocgenServer: process.env.STORYBOOK_EXPERIMENTAL_DOCGEN_SERVER === 'true', + experimentalReactComponentMeta: true, changeDetection: true, + experimentalReview: true, }, staticDirs: [{ from: './bench/bundle-analyzer', to: '/bundle-analyzer' }], - viteFinal: async (viteConfig, { configType }) => { + viteFinal: async (viteConfig: InlineConfig, { configType }: Options) => { const { mergeConfig } = await import('vite'); return mergeConfig(viteConfig, { @@ -162,12 +175,14 @@ const config = defineMain({ ? { 'storybook/internal/components': componentsPath, 'storybook/manager-api': managerApiPath, + 'storybook/preview-api': previewApiPath, 'storybook/theming/create': themingCreatePath, 'storybook/theming': themingPath, 'sb-original/image-context': imageContextPath, } : { 'storybook/manager-api': managerApiPath, + 'storybook/preview-api': previewApiPath, }, }, plugins: [react()], @@ -179,12 +194,17 @@ const config = defineMain({ server: { watch: { // Something odd happens with tsconfig and nx which causes Storybook to keep reloading, so we ignore them - ignored: ['**/.nx/cache/**', '**/tsconfig.json'], + ignored: [ + '**/.nx/cache/**', + '**/tsconfig.json', + // Internal e2e writes traces under code/playwright-results while the dev server is running. + '**/playwright-results/**', + '**/playwright-report/**', + ], }, }, } satisfies typeof viteConfig); }, - // logLevel: 'debug', }); export default config; diff --git a/code/.storybook/manager.tsx b/code/.storybook/manager.tsx index 890e86b6e394..b2436c5b3dd8 100644 --- a/code/.storybook/manager.tsx +++ b/code/.storybook/manager.tsx @@ -7,3 +7,5 @@ addons.setConfig({ renderLabel: ({ name, type }) => (type === 'story' ? name : startCase(name)), }, }); + +import '../core/src/shared/open-service/sync-test/manager.tsx'; diff --git a/code/.storybook/open-service-debug-service.ts b/code/.storybook/open-service-debug-service.ts new file mode 100644 index 000000000000..af01c2af0265 --- /dev/null +++ b/code/.storybook/open-service-debug-service.ts @@ -0,0 +1,189 @@ +import * as v from 'valibot'; + +import { logger } from 'storybook/internal/node-logger'; +import type { StoryIndexGenerator } from '../core/src/core-server/utils/StoryIndexGenerator.ts'; + +import { defineService } from 'storybook/open-service'; +import { describeService, registerService } from '../core/src/shared/open-service/server.ts'; + +const DEBUG_SERVICE_ID = 'storybook/internal/open-service-debug'; + +type DebugServiceState = { + activity: string[]; + preloadedByEntryId: Record; + lastObservedValue: string | null; + storyIndexEntryCount: number; + storyIndexSampleIds: string[]; +}; + +const messageInputSchema = v.object({ message: v.string() }); +const entryInputSchema = v.object({ entryId: v.string() }); +const activityQueryInputSchema = v.object({ limit: v.number() }); +const preloadVisitInputSchema = v.object({ + entryId: v.string(), + source: v.string(), +}); +const storyIndexSummaryInputSchema = v.object({ includeSampleIds: v.boolean() }); +const storyIndexSummaryOutputSchema = v.object({ + entryCount: v.number(), + sampleIds: v.array(v.string()), +}); +const syncStoryIndexInputSchema = v.object({ reason: v.string() }); + +function createDebugServiceDef(storyIndexGeneratorPromise: Promise) { + return defineService({ + id: DEBUG_SERVICE_ID, + description: + 'Exercises Storybook open-service registration, queries, commands, loads, subscriptions, static builds, and story-index integration inside the internal Storybook.', + initialState: { + activity: [], + preloadedByEntryId: {}, + lastObservedValue: null, + storyIndexEntryCount: 0, + storyIndexSampleIds: [], + } as DebugServiceState, + queries: { + activity: { + description: 'Returns the latest activity entries for the debug service.', + input: activityQueryInputSchema, + output: v.array(v.string()), + handler: (input, ctx) => { + logger.warn('[open-service debug] query activity'); + return ctx.self.state.activity.slice(-input.limit); + }, + }, + storyIndexSummary: { + description: 'Returns story-index-derived summary data captured by the debug service.', + input: storyIndexSummaryInputSchema, + output: storyIndexSummaryOutputSchema, + handler: (input, ctx) => { + logger.warn('[open-service debug] query storyIndexSummary'); + return { + entryCount: ctx.self.state.storyIndexEntryCount, + sampleIds: input.includeSampleIds ? ctx.self.state.storyIndexSampleIds : [], + }; + }, + }, + preloadedValue: { + description: + 'Returns a preloaded value for one entry id and participates in static builds.', + input: entryInputSchema, + output: v.nullable(v.string()), + load: async (input, ctx) => { + logger.warn(`[open-service debug] load preloadedValue(${input.entryId})`); + if (ctx.self.state.preloadedByEntryId[input.entryId] !== undefined) { + return; + } + + await ctx.self.commands.recordPreloadVisit({ + entryId: input.entryId, + source: 'load', + }); + }, + staticPath: (input) => `${input.entryId}.json`, + staticInputs: async () => [{ entryId: 'static-a' }, { entryId: 'static-b' }], + handler: (input, ctx) => { + const value = ctx.self.state.preloadedByEntryId[input.entryId] ?? null; + + logger.warn(`[open-service debug] query preloadedValue(${input.entryId}) => ${value}`); + return value; + }, + }, + }, + commands: { + addActivity: { + description: 'Appends one entry to the debug activity log.', + input: messageInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + logger.warn(`[open-service debug] command addActivity(${input.message})`); + ctx.self.setState((state) => { + state.activity.push(input.message); + }); + + return undefined; + }, + }, + syncStoryIndex: { + description: 'Reads the current story index and stores a compact summary in service state.', + input: syncStoryIndexInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + const storyIndex = await (await storyIndexGeneratorPromise).getIndex(); + const sampleIds = Object.keys(storyIndex.entries).slice(0, 5); + + logger.warn( + `[open-service debug] command syncStoryIndex(${input.reason}) => ${Object.keys(storyIndex.entries).length} entries` + ); + ctx.self.setState((state) => { + state.storyIndexEntryCount = Object.keys(storyIndex.entries).length; + state.storyIndexSampleIds = sampleIds; + state.activity.push(`syncStoryIndex:${input.reason}:${sampleIds.length}`); + }); + + return undefined; + }, + }, + recordPreloadVisit: { + description: 'Stores a generated value for one entry id and records the visit.', + input: preloadVisitInputSchema, + output: v.undefined(), + handler: async (input, ctx) => { + const summary = ctx.self.queries.storyIndexSummary.get({ + includeSampleIds: false, + }); + const value = `${input.source}:${input.entryId}:${summary.entryCount}`; + + logger.warn( + `[open-service debug] command recordPreloadVisit(${input.entryId}, ${input.source}) => ${value}` + ); + ctx.self.setState((state) => { + state.preloadedByEntryId[input.entryId] = value; + state.lastObservedValue = value; + state.activity.push(`recordPreloadVisit:${input.entryId}:${input.source}`); + }); + + return undefined; + }, + }, + }, + }); +} + +/** + * Registers the internal Storybook debug service that exercises the server-side open-service + * features in one place. + * + * The service self-demonstrates queries, commands, loads, subscriptions, static snapshot + * generation, and story-index integration inside the internal Storybook. It is gated behind the + * `STORYBOOK_OPEN_SERVICE_DEBUG=true` env flag in `code/.storybook/services-preset.ts`. + */ +export async function registerOpenServiceDebugService( + storyIndexGeneratorPromise: Promise +): Promise { + const service = registerService(createDebugServiceDef(storyIndexGeneratorPromise)); + const descriptor = await describeService(DEBUG_SERVICE_ID); + + logger.warn('[open-service debug] registered service descriptor'); + logger.warn(JSON.stringify(descriptor, null, 2)); + + const unsubscribe = service.queries.preloadedValue.subscribe( + { entryId: 'startup' }, + ({ data }) => { + logger.warn(`[open-service debug] subscription preloadedValue(startup) => ${data}`); + } + ); + + try { + // Trigger the main runtime behaviors once during registration so debug logs immediately show + // the command, query, load, and subscription paths without extra manual setup. + await service.commands.syncStoryIndex({ reason: 'services-preset' }); + await service.commands.addActivity({ message: 'registered via services preset' }); + service.queries.activity.get({ limit: 10 }); + service.queries.storyIndexSummary.get({ includeSampleIds: true }); + await service.queries.preloadedValue.loaded({ entryId: 'startup' }); + await new Promise((resolve) => queueMicrotask(resolve)); + } finally { + unsubscribe(); + } +} diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index ec3fb2a8fd75..e6258be49aaf 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -51,8 +51,23 @@ sb.mock(import('lodash-es/sum')); sb.mock(import('uuid')); /* eslint-enable depend/ban-dependencies */ +import '../core/src/shared/open-service/sync-test/preview.ts'; + const { document } = global; -globalThis.CONFIG_TYPE = 'DEVELOPMENT'; + +// The internal Storybook's manager stories (sidebar context menu, onboarding guide, …) demonstrate +// DEVELOPMENT-only UI, so they must render as if in development even though this Storybook is itself +// built and served statically (`yarn storybook:ui`, Chromatic, Vitest portable stories). In Vitest the +// preview builder never injects CONFIG_TYPE (it is undefined); in Chromatic it is injected as PRODUCTION. +// The one exception is the open-service static-load E2E, which serves a plain production build to +// exercise the real static-loading path — there CONFIG_TYPE is PRODUCTION and Chromatic is not driving +// the browser, so we leave the injected value untouched. +// TODO(open-service): #29743 added an unconditional `CONFIG_TYPE = 'DEVELOPMENT'` here with no rationale. +// Ask Norbert why the internal Storybook needs to force development mode rather than the stories opting +// in per-story; if it can be removed, this gate should go with it. +if (isChromatic() || globalThis.CONFIG_TYPE !== 'PRODUCTION') { + globalThis.CONFIG_TYPE = 'DEVELOPMENT'; +} const ThemeBlock = styled.div<{ side: 'left' | 'right'; layout: string }>( { @@ -341,7 +356,11 @@ const decorators = [ ] satisfies Decorator[]; const parameters = { + // Redundant but useful for testing lang parameter propagation in stories. + htmlLang: 'en-CA', docs: { + // Redundant but useful for testing lang parameter propagation in docs. + lang: 'en-US', theme: themes.light, codePanel: true, source: { diff --git a/code/.storybook/services-preset.ts b/code/.storybook/services-preset.ts new file mode 100644 index 000000000000..c15cbe621937 --- /dev/null +++ b/code/.storybook/services-preset.ts @@ -0,0 +1,21 @@ +import type { Options, StorybookConfigRaw } from 'storybook/internal/types'; + +import { registerOpenServiceSyncDemos } from '../core/src/shared/open-service/sync-test/server.ts'; +import { registerOpenServiceDebugService } from './open-service-debug-service.ts'; + +/** + * Preset hook that registers internal open-service examples and the opt-in debug service. + * + * Set `STORYBOOK_OPEN_SERVICE_DEBUG=true` to additionally register the debug service. + */ +export const services = async (_value: void, options: Options): Promise => { + registerOpenServiceSyncDemos(); + + if (process.env.STORYBOOK_OPEN_SERVICE_DEBUG === 'true') { + await registerOpenServiceDebugService( + options.presets.apply>( + 'storyIndexGenerator' + ) + ); + } +}; diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts index 875d71242c63..8c87819e80d7 100644 --- a/code/.storybook/storybook.setup.ts +++ b/code/.storybook/storybook.setup.ts @@ -5,6 +5,7 @@ import { setProjectAnnotations } from '@storybook/react'; import { userEvent as storybookEvent, expect as storybookExpect } from 'storybook/test'; import '../core/src/shared/utils/toHaveLiveRegion.ts'; + import preview from './preview.tsx'; vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); diff --git a/code/.storybook/tsconfig.json b/code/.storybook/tsconfig.json new file mode 100644 index 000000000000..a32dd4f239d6 --- /dev/null +++ b/code/.storybook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*"] +} diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index c3a3c124b2e6..e2c9ac6ecbae 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-a11y", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Addon A11y: Test UI component compliance with WCAG web accessibility standards", "keywords": [ "a11y", diff --git a/code/addons/a11y/src/a11yRunner.test.ts b/code/addons/a11y/src/a11yRunner.test.ts index 9018e7417c6e..3edeaa1977e7 100644 --- a/code/addons/a11y/src/a11yRunner.test.ts +++ b/code/addons/a11y/src/a11yRunner.test.ts @@ -1,3 +1,4 @@ +import type { AxeResults } from 'axe-core'; import type { Mock } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -5,17 +6,57 @@ import { addons } from 'storybook/preview-api'; import { EVENTS } from './constants.ts'; +const { axeMock, documentMock } = vi.hoisted(() => { + const documentMock = { + body: {}, + getElementById: vi.fn(), + location: { pathname: '/iframe.html' }, + }; + + return { + documentMock, + axeMock: { + reset: vi.fn(), + configure: vi.fn(), + run: vi.fn(), + }, + }; +}); + +vi.mock('@storybook/global', () => ({ + global: { + document: documentMock, + }, +})); + +vi.mock('axe-core', () => ({ + default: axeMock, +})); + vi.mock('storybook/preview-api'); const mockedAddons = vi.mocked(addons); +const axeResults = { + violations: [], + passes: [], + incomplete: [], + inapplicable: [], +} as Partial as AxeResults; + describe('a11yRunner', () => { let mockChannel: { on: Mock; emit?: Mock }; beforeEach(() => { - mockedAddons.getChannel.mockReset(); + vi.resetModules(); + vi.clearAllMocks(); + + documentMock.getElementById.mockReturnValue(null); + axeMock.run.mockResolvedValue(axeResults); mockChannel = { on: vi.fn(), emit: vi.fn() }; - mockedAddons.getChannel.mockReturnValue(mockChannel as any); + mockedAddons.getChannel.mockReturnValue( + mockChannel as unknown as ReturnType + ); }); it('should listen to events', async () => { @@ -24,4 +65,67 @@ describe('a11yRunner', () => { expect(mockedAddons.getChannel).toHaveBeenCalled(); expect(mockChannel.on).toHaveBeenCalledWith(EVENTS.MANUAL, expect.any(Function)); }); + + it('passes disabled configured rules to axe.run when runOnly is present', async () => { + const { run } = await import('./a11yRunner.ts'); + const input = { + config: { + rules: [ + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }, + options: { + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }, + }; + + await run(input, 'example-story'); + + expect(axeMock.configure).toHaveBeenCalledWith({ + rules: [ + { id: 'region', enabled: false }, + { id: 'target-size', enabled: false }, + { id: 'color-contrast', enabled: true }, + ], + }); + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + rules: { + region: { enabled: false }, + 'target-size': { enabled: false }, + 'button-name': { enabled: false }, + }, + }); + expect(axeMock.run.mock.calls[0][1]).not.toBe(input.options); + expect(input.options).toEqual({ + runOnly: ['wcag2a'], + rules: { + 'button-name': { enabled: false }, + }, + }); + }); + + it('respects configured rule overrides when collecting disabled rules', async () => { + const { run } = await import('./a11yRunner.ts'); + + await run( + { + config: { + rules: [{ id: 'region', enabled: true }], + }, + options: { + runOnly: ['wcag2a'], + }, + }, + 'example-story' + ); + + expect(axeMock.run).toHaveBeenCalledWith(expect.any(Object), { + runOnly: ['wcag2a'], + }); + }); }); diff --git a/code/addons/a11y/src/a11yRunner.ts b/code/addons/a11y/src/a11yRunner.ts index 00fb4540f8a7..b5aa244c6a84 100644 --- a/code/addons/a11y/src/a11yRunner.ts +++ b/code/addons/a11y/src/a11yRunner.ts @@ -2,7 +2,7 @@ import { ElementA11yParameterError } from 'storybook/internal/preview-errors'; import { global } from '@storybook/global'; -import type { AxeResults, ContextProp, ContextSpec } from 'axe-core'; +import type { AxeResults, ContextProp, ContextSpec, RunOptions, Spec } from 'axe-core'; import { addons, waitForAnimations } from 'storybook/preview-api'; import { withLinkPaths } from './a11yRunnerUtils.ts'; @@ -21,6 +21,47 @@ const DISABLED_RULES = [ 'region', ] as const; +const getDisabledRules = (rules: Spec['rules'] = []) => { + const disabledRules: NonNullable = {}; + + // Rules are applied in order, so a later entry overrides an earlier one for the same id. + for (const { id, enabled } of rules) { + if (!id || typeof enabled !== 'boolean') { + continue; + } + + if (enabled) { + delete disabledRules[id]; + } else { + disabledRules[id] = { enabled: false }; + } + } + + return disabledRules; +}; + +const mergeDisabledRulesIntoRunOptions = (options: RunOptions, config: Spec): RunOptions => { + // axe.run({ runOnly }) can re-enable tagged rules, so mirror configured disables into + // the same run options object without mutating the user's parameters. + if (!options.runOnly) { + return options; + } + + const disabledRules = getDisabledRules(config.rules); + + if (Object.keys(disabledRules).length === 0) { + return options; + } + + return { + ...options, + rules: { + ...disabledRules, + ...options.rules, + }, + }; +}; + // A simple queue to run axe-core in sequence // This is necessary because axe-core is not designed to run in parallel const queue: (() => Promise)[] = []; @@ -92,6 +133,8 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s axe.configure(configWithDefault); + const optionsWithDisabledRules = mergeDisabledRulesIntoRunOptions(options, configWithDefault); + return new Promise((resolve, reject) => { const highlightsRoot = document?.getElementById('storybook-highlights-root'); if (highlightsRoot) { @@ -100,7 +143,7 @@ export const run = async (input: A11yParameters = DEFAULT_PARAMETERS, storyId: s const task = async () => { try { - const result = await axe.run(context, options); + const result = await axe.run(context, optionsWithDisabledRules); const resultWithLinks = withLinkPaths(result, storyId); resolve(resultWithLinks); } catch (error) { diff --git a/code/addons/docs/package.json b/code/addons/docs/package.json index c835dc762e42..9cdc1608d058 100644 --- a/code/addons/docs/package.json +++ b/code/addons/docs/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-docs", - "version": "10.4.0-alpha.19", + "version": "10.5.0-beta.1", "description": "Storybook Docs: Document UI components automatically with stories and MDX", "keywords": [ "docs", @@ -106,6 +106,7 @@ "memoizerific": "^1.11.3", "polished": "^4.2.2", "react": "^18.2.0", + "react-aria": "patch:react-aria@npm%3A3.48.0#~/.yarn/patches/react-aria-npm-3.48.0-0945840d84.patch", "react-colorful": "^5.1.2", "react-dom": "^18.2.0", "rehype-external-links": "^3.0.0", diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.mdx b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx new file mode 100644 index 000000000000..cee0bd2720af --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.mdx @@ -0,0 +1,73 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ArgTypesStories from './ArgTypes.stories.tsx'; +import * as ExampleStories from '../examples/ArgTypesParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; + +import { ArgTypes } from './ArgTypes'; + + + +# ArgTypes block (MDX) + +Each section below uses the `ArgTypes` block the same way as the matching story in `ArgTypes.stories.tsx`. + +## OfComponent + + + +## OfMeta + + + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfMeta + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx index 63f493e39313..fb9d13bc8f11 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.stories.tsx @@ -1,16 +1,13 @@ -import React from 'react'; - +/** Custom docs page: {@link ./ArgTypes.mdx} (attached via ``). */ import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { within } from 'storybook/test'; - import * as ExampleStories from '../examples/ArgTypesParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ArgTypesWithSubcomponentsParameters.stories'; import { ArgTypes } from './ArgTypes'; -const meta: Meta = { +const meta = { title: 'Blocks/ArgTypes', component: ArgTypes, parameters: { @@ -21,7 +18,8 @@ const meta: Meta = { ], docsStyles: true, }, -}; +} satisfies Meta; + export default meta; type Story = StoryObj; @@ -108,7 +106,7 @@ export const Categories: Story = { }; const findSubcomponentTabs = async ( - canvas: ReturnType, + canvas: Parameters>[0]['canvas'], step: PlayFunctionContext['step'] ) => { let subcomponentATab: HTMLElement | null = null; @@ -124,17 +122,18 @@ export const SubcomponentsOfMeta: Story = { args: { of: SubcomponentsExampleStories.default, }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { await findSubcomponentTabs(canvas, step); }, }; export const SubcomponentsOfStory: Story = { - ...SubcomponentsOfMeta, args: { of: SubcomponentsExampleStories.NoParameters, }, + play: async ({ canvas, step }) => { + await findSubcomponentTabs(canvas, step); + }, }; export const SubcomponentsIncludeProp: Story = { @@ -142,8 +141,7 @@ export const SubcomponentsIncludeProp: Story = { of: SubcomponentsExampleStories.NoParameters, include: ['a', 'f'], }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); + play: async ({ canvas, step }) => { const { subcomponentBTab } = await findSubcomponentTabs(canvas, step); if (subcomponentBTab) { await (subcomponentBTab as HTMLElement & { click: () => Promise }).click(); diff --git a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx index 3d0d343c553a..93b5b409d739 100644 --- a/code/addons/docs/src/blocks/blocks/ArgTypes.tsx +++ b/code/addons/docs/src/blocks/blocks/ArgTypes.tsx @@ -1,9 +1,7 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; import type { ModuleExports } from 'storybook/internal/types'; @@ -11,10 +9,16 @@ import type { PropDescriptor } from 'storybook/preview-api'; import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; -import { useOf } from './useOf'; -import { getComponentName } from './utils'; -import { withMdxComponentOverride } from './with-mdx-component-override'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { + extractComponentArgTypes, + extractSubcomponentArgTypes, + useDocgenServiceRows, +} from './argTypesShared'; +import { DocsContext } from './DocsContext.ts'; +import { useOf } from './useOf.ts'; +import { getComponentName } from './utils.ts'; +import { withMdxComponentOverride } from './with-mdx-component-override.tsx'; type ArgTypesParameters = { include?: PropDescriptor; @@ -25,85 +29,166 @@ type ArgTypesParameters = { type ArgTypesProps = ArgTypesParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + +type ResolvedArgTypes = { + parameters: Parameters; + componentId: string; + storyId?: string; + initialArgs?: Args; + argTypes?: StrictArgTypes; + component?: Renderer['component']; + subcomponents?: Record; + filterProps: ArgTypesParameters; +}; + +function useResolveArgTypes(props: ArgTypesProps): ResolvedArgTypes { + const { of } = props; + if ('of' in props && of === undefined) { + throw new InvalidBlockOfPropError(); } - return extractArgTypes(component) as StrictArgTypes; -} + const context = useContext(DocsContext); + const resolved = useOf(of || 'meta'); -function getArgTypesFromResolved(resolved: ReturnType) { + let resolvedArgTypes: Omit; if (resolved.type === 'component') { const { component, projectAnnotations: { parameters }, } = resolved; - return { + resolvedArgTypes = { + parameters: parameters as Parameters, + // Bare `of={Component}` has no story/meta annotations; the docgen service is addressed by + // component id, recovered from the CSF file that declares this component. + componentId: context.getComponentId(component)!, argTypes: extractComponentArgTypes(component, parameters as Parameters), + component, + }; + } else if (resolved.type === 'meta') { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = + resolved.preparedMeta; + resolvedArgTypes = { parameters, + componentId: id.split('--')[0], + initialArgs, + argTypes, component, + subcomponents, + }; + } else { + const { id, argTypes, parameters, initialArgs, component, subcomponents } = resolved.story; + resolvedArgTypes = { + parameters, + componentId: id.split('--')[0], + storyId: id, + initialArgs, + argTypes, + component, + subcomponents, }; } - if (resolved.type === 'meta') { - const { - preparedMeta: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; + const argTypesParameters = + resolvedArgTypes.parameters?.docs?.argTypes || ({} as ArgTypesParameters); + + return { + ...resolvedArgTypes, + filterProps: { + include: props.include ?? argTypesParameters.include, + exclude: props.exclude ?? argTypesParameters.exclude, + sort: props.sort ?? argTypesParameters.sort, + }, + }; +} + +function renderArgTypesTables({ + mainName = 'Main', + mainRows, + subcomponentRows, + include, + exclude, + sort, + docsLang, +}: { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; + docsLang?: string; +}) { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + return ; } - // In the case of the story, the enhanceArgs argTypeEnhancer has already added the extracted - // arg types from the component to the prepared story. - const { - story: { argTypes, parameters, component, subcomponents }, - } = resolved; - return { argTypes, parameters, component, subcomponents }; + const tabs = { + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), + }; + + return ; } -const ArgTypesImpl: FC = (props) => { - const { of } = props; - if ('of' in props && of === undefined) { - throw new InvalidBlockOfPropError(); +const LegacyArgTypes: FC = (props) => { + const { argTypes, parameters, component, subcomponents, filterProps } = useResolveArgTypes(props); + + if (!argTypes) { + return null; } - const resolved = useOf(of || 'meta'); - const { argTypes, parameters, component, subcomponents } = getArgTypesFromResolved(resolved); - const argTypesParameters = parameters?.docs?.argTypes || ({} as ArgTypesParameters); - const include = props.include ?? argTypesParameters.include; - const exclude = props.exclude ?? argTypesParameters.exclude; - const sort = props.sort ?? argTypesParameters.sort; + return renderArgTypesTables({ + mainName: getComponentName(component), + mainRows: argTypes, + subcomponentRows: extractSubcomponentArgTypes(subcomponents, parameters), + docsLang: parameters?.docs?.lang, + ...filterProps, + }); +}; - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); +const DocgenServiceArgTypes: FC = (props) => { + const { argTypes, parameters, componentId, storyId, initialArgs, filterProps, component } = + useResolveArgTypes(props); + const { rows: serviceRows, isInitialLoading } = useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes: argTypes, + }); - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + if (isInitialLoading) { + return ; + } - if (!hasSubcomponents) { - return ; + if (!serviceRows) { + return null; } - const mainComponentName = getComponentName(component) || 'Main'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes( - extractComponentArgTypes(comp, parameters as Parameters), - include, - exclude - ), - sort, - }, - ]) + return renderArgTypesTables({ + mainName: getComponentName(component) ?? serviceRows.serviceComponentName, + mainRows: serviceRows.mainRows, + subcomponentRows: serviceRows.subcomponentRows, + docsLang: parameters?.docs?.lang, + ...filterProps, + }); +}; + +const ArgTypesImpl: FC = (props) => { + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + ); - const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, - }; - return ; }; export const ArgTypes = withMdxComponentOverride('ArgTypes', ArgTypesImpl); diff --git a/code/addons/docs/src/blocks/blocks/Controls.mdx b/code/addons/docs/src/blocks/blocks/Controls.mdx new file mode 100644 index 000000000000..fec7bba1c16e --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/Controls.mdx @@ -0,0 +1,76 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as ControlsStories from './Controls.stories.tsx'; +import * as ExampleStories from '../examples/ControlsParameters.stories'; +import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; +import * as EmptyArgTypesStories from '../examples/EmptyArgTypes.stories'; + +import { Controls } from './Controls'; + + + +# Controls block (MDX) + +Each section below uses the `Controls` block the same way as the matching story in `Controls.stories.tsx`. + +## OfStory + + + +## IncludeProp + + + +## IncludeParameter + + + +## ExcludeProp + + + +## ExcludeParameter + + + +## SortProp + + + +## SortParameter + + + +## Categories + + + +## SubcomponentsOfStory + + + +## SubcomponentsIncludeProp + + + +## SubcomponentsExcludeProp + + + +## SubcomponentsSortProp + + + +## EmptyArgTypes + + + +## MultipleControlsOnSamePage + + + + +## MultipleControlsForSameStoryOnSamePage + + + diff --git a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx index 0a312c1e2f55..4c917d5dded4 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.stories.tsx @@ -1,10 +1,11 @@ +/** Custom docs page: {@link ./Controls.mdx} (attached via ``). */ import React from 'react'; import type { PlayFunctionContext } from 'storybook/internal/csf'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import * as ExampleStories from '../examples/ControlsParameters.stories'; import * as SubcomponentsExampleStories from '../examples/ControlsWithSubcomponentsParameters.stories'; @@ -120,6 +121,37 @@ export const SubcomponentsOfStory: Story = { }, }; +/** + * When a component declares subcomponents, editing a control on the main component tab should not + * remount the input and drop focus. This verifies the fix for + * https://github.com/storybookjs/storybook/issues/29028 + */ +export const SubcomponentsRetainControlFocus: Story = { + args: { + of: SubcomponentsExampleStories.NoParameters, + }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('bx') ?? canvas.queryByDisplayValue('b'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'b'); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByDisplayValue('b'); + await userEvent.click(input); + await userEvent.type(input, 'x'); + await waitFor(() => { + expect(document.activeElement).toBe(input); + }); + }, +}; + export const SubcomponentsIncludeProp: Story = { args: { of: SubcomponentsExampleStories.NoParameters, @@ -181,3 +213,25 @@ export const MultipleControlsOnSamePage: Story = { await expect(uniqueIds.size).toBe(allIds.length); }, }; + +/** + * When multiple Controls blocks for the SAME story are on the same docs page, each control should + * still have a unique id (and unique name across blocks, so that radio button groups remain + * independent). This verifies the fix for https://github.com/storybookjs/storybook/issues/29295. + */ +export const MultipleControlsForSameStoryOnSamePage: Story = { + render: () => ( + <> + + + > + ), + play: async ({ canvasElement }) => { + const allIds = Array.from(canvasElement.querySelectorAll('[id^="control-"]')).map( + (el) => el.id + ); + const uniqueIds = new Set(allIds); + await expect(allIds.length).toBeGreaterThan(0); + await expect(uniqueIds.size).toBe(allIds.length); + }, +}; diff --git a/code/addons/docs/src/blocks/blocks/Controls.tsx b/code/addons/docs/src/blocks/blocks/Controls.tsx index 5110ba6687c0..4d69ec8cde17 100644 --- a/code/addons/docs/src/blocks/blocks/Controls.tsx +++ b/code/addons/docs/src/blocks/blocks/Controls.tsx @@ -1,16 +1,17 @@ -/* eslint-disable react/destructuring-assignment */ import type { FC } from 'react'; import React, { useContext } from 'react'; -import type { Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; -import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; -import type { ModuleExports } from 'storybook/internal/types'; +import { useId } from 'react-aria/useId'; + +import type { Args, Globals, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { DocsContextProps, ModuleExports, PreparedStory } from 'storybook/internal/types'; -import { filterArgTypes } from 'storybook/preview-api'; import type { PropDescriptor } from 'storybook/preview-api'; +import { filterArgTypes } from 'storybook/preview-api'; import type { SortType } from '../components'; -import { ArgsTableError, ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { ArgsTable as PureArgsTable, TabbedArgsTable } from '../components'; +import { extractSubcomponentArgTypes, useDocgenServiceRows } from './argTypesShared'; import { DocsContext } from './DocsContext'; import { useArgs } from './useArgs'; import { useGlobals } from './useGlobals'; @@ -28,73 +29,104 @@ type ControlsProps = ControlsParameters & { of?: Renderer['component'] | ModuleExports; }; -function extractComponentArgTypes( - component: Renderer['component'], - parameters: Parameters -): StrictArgTypes { - const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; - if (!extractArgTypes) { - throw new Error(ArgsTableError.ARGS_UNSUPPORTED); - } - return extractArgTypes(component) as StrictArgTypes; -} - -const ControlsImpl: FC = (props) => { - const { of } = props; - const context = useContext(DocsContext); - const primaryStory = usePrimaryStory(); +type ControlsStoryProps = ControlsProps & { + story: PreparedStory; + context: DocsContextProps; +}; - const story = of ? context.resolveOf(of, ['story']).story : primaryStory; +type ControlsInteractiveState = { + controlsId: string; + args: Args; + globals: Globals; + updateArgs: ReturnType[1]; + resetArgs: ReturnType[2]; +}; - if (!story) { - return null; - } +type ControlsTablesProps = ControlsInteractiveState & { + mainName?: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; + include?: PropDescriptor; + exclude?: PropDescriptor; + sort?: SortType; + storyId: string; + docsLang?: string; +}; - const { parameters, argTypes, component, subcomponents } = story; - const controlsParameters = parameters.docs?.controls || ({} as ControlsParameters); +function getControlsFilterProps(story: PreparedStory, props: ControlsProps): ControlsParameters { + const controlsParameters = story.parameters.docs?.controls || ({} as ControlsParameters); - const include = props.include ?? controlsParameters.include; - const exclude = props.exclude ?? controlsParameters.exclude; - const sort = props.sort ?? controlsParameters.sort; + return { + include: props.include ?? controlsParameters.include, + exclude: props.exclude ?? controlsParameters.exclude, + sort: props.sort ?? controlsParameters.sort, + }; +} +function useControlsInteractiveState( + story: PreparedStory, + context: DocsContextProps +): ControlsInteractiveState { + // Disambiguate multiple blocks rendered for the same story on a single page. + // React Aria's useId gives a stable id per component instance, with a polyfill for + // React versions that lack the built-in useId. + const controlsId = useId(); const [args, updateArgs, resetArgs] = useArgs(story, context); const [globals] = useGlobals(story, context); - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); - - const hasSubcomponents = Boolean(subcomponents) && Object.keys(subcomponents || {}).length > 0; + return { controlsId, args, globals, updateArgs, resetArgs }; +} - if (!hasSubcomponents) { - if (!(Object.keys(filteredArgTypes).length > 0 || Object.keys(args).length > 0)) { +const ControlsTables: FC = ({ + mainName = 'Story', + mainRows, + subcomponentRows, + include, + exclude, + sort, + storyId, + controlsId, + args, + globals, + updateArgs, + resetArgs, + docsLang, +}) => { + const filteredMainRows = filterArgTypes(mainRows, include, exclude); + + if (Object.keys(subcomponentRows).length === 0) { + if (!(Object.keys(filteredMainRows).length > 0 || Object.keys(args).length > 0)) { return null; } + return ( ); } - const mainComponentName = getComponentName(component) || 'Story'; - const subcomponentTabs = Object.fromEntries( - Object.entries(subcomponents || {}).map(([key, comp]) => [ - key, - { - rows: filterArgTypes(extractComponentArgTypes(comp, parameters), include, exclude), - sort, - }, - ]) - ); const tabs = { - [mainComponentName]: { rows: filteredArgTypes, sort }, - ...subcomponentTabs, + [mainName]: { rows: filteredMainRows, sort }, + ...Object.fromEntries( + Object.entries(subcomponentRows).map(([key, rows]) => [ + key, + { + rows: filterArgTypes(rows, include, exclude), + sort, + }, + ]) + ), }; + return ( = (props) => { globals={globals} updateArgs={updateArgs} resetArgs={resetArgs} + storyId={storyId} + controlsId={controlsId} + docsLang={docsLang} + /> + ); +}; + +const LegacyControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component, subcomponents } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + + if (!argTypes) { + return null; + } + + return ( + + ); +}; + +const DocgenServiceControls: FC = ({ story, context, ...props }) => { + const { parameters, argTypes, component } = story; + const filterProps = getControlsFilterProps(story, props); + const interactiveState = useControlsInteractiveState(story, context); + const { rows: serviceRows, isInitialLoading } = useDocgenServiceRows({ + componentId: story.id.split('--')[0], + storyId: story.id, + parameters, + initialArgs: story.initialArgs, + customArgTypes: argTypes, + }); + + if (isInitialLoading) { + return ; + } + + if (!serviceRows) { + return null; + } + + return ( + ); }; +const ControlsImpl: FC = (props) => { + const { of } = props; + const context = useContext(DocsContext); + const primaryStory = usePrimaryStory(); + + const story = of ? context.resolveOf(of, ['story']).story : primaryStory; + + if (!story) { + return null; + } + + const storyProps = { ...props, story, context }; + + return globalThis.FEATURES?.experimentalDocgenServer ? ( + + ) : ( + + ); +}; + export const Controls = withMdxComponentOverride('Controls', ControlsImpl); diff --git a/code/addons/docs/src/blocks/blocks/Description.stories.tsx b/code/addons/docs/src/blocks/blocks/Description.stories.tsx index 2e78949e756b..6d8611f37cf9 100644 --- a/code/addons/docs/src/blocks/blocks/Description.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.stories.tsx @@ -1,13 +1,90 @@ -import React from 'react'; +import type { ModuleExport, StoryContext, StoryDocsPayload } from 'storybook/internal/types'; +import { registerService } from 'storybook/preview-api'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; +import invariant from 'tiny-invariant'; + import { Button as ButtonComponent } from '../examples/Button'; import * as DefaultButtonStories from '../examples/Button.stories'; import * as ButtonStoriesWithMetaDescriptionAsBoth from '../examples/ButtonWithMetaDescriptionAsBoth.stories'; import * as ButtonStoriesWithMetaDescriptionAsComment from '../examples/ButtonWithMetaDescriptionAsComment.stories'; import * as ButtonStoriesWithMetaDescriptionAsParameter from '../examples/ButtonWithMetaDescriptionAsParameter.stories'; +import * as ParametersStories from '../examples/SourceParameters.stories.tsx'; import { Description } from './Description'; +import type { DocsContextProps } from './DocsContext.ts'; +import { storyDocsServiceDef } from '../../../../../core/src/shared/open-service/services/story-docs/definition.ts'; +import { unregisterService } from '../../../../../core/src/shared/open-service/service-registry.ts'; + +type StoryDocsMockData = { + description?: string; +}; + +function createStoryDocsPayload( + docsContext: DocsContextProps, + of: ModuleExport, + data: StoryDocsMockData +): StoryDocsPayload { + const { story } = docsContext.resolveOf(of, ['story']); + const componentId = story.id.split('--')[0]!; + + return { + id: componentId, + name: componentId, + path: './example.stories.tsx', + stories: { + [story.id]: { + id: story.id, + name: story.name, + ...(data.description ? { description: data.description } : {}), + }, + }, + }; +} + +function storyDocsServiceStoryBeforeEach(of: ModuleExport, data: StoryDocsMockData) { + return async (context: StoryContext) => { + const docsContext = context.loaded?.docsContext as DocsContextProps | undefined; + invariant(docsContext, 'docsContext is required to mock story-docs for docs block stories'); + + // The docs blocks only consume the story-docs service when this feature is enabled, but it is + // disabled by default in production builds (e.g. Chromatic). Enable it here so the service-backed + // story renders the mocked data instead of falling back to the non-service path. + const previousFeatures = globalThis.FEATURES; + globalThis.FEATURES = { ...previousFeatures, experimentalDocgenServer: true }; + + const payload = createStoryDocsPayload(docsContext, of, data); + unregisterService('core/story-docs'); + const service = registerService(storyDocsServiceDef, { + commands: { + extractStoryDocs: { + handler: (input, ctx) => { + ctx.self.setState((state) => { + state.components[input.id] = payload; + }); + return payload; + }, + }, + extractAllStoryDocs: { + handler: (_input, ctx) => { + ctx.self.setState((state) => { + state.components[payload.id] = payload; + }); + }, + }, + }, + }); + await service.commands.extractStoryDocs({ id: payload.id }); + + return () => { + unregisterService('core/story-docs'); + globalThis.FEATURES = previousFeatures; + }; + }; +} + +const SERVICE_STORY_DESCRIPTION = 'Description from the story-docs service'; const meta: Meta = { component: Description, @@ -110,6 +187,20 @@ export const OfStoryAsStoryCommentAndParameter: Story = { }, parameters: { relativeCsfPaths: ['../examples/Button.stories'] }, }; + +export const OfStoryFromStoryDocsService: Story = { + args: { + of: ParametersStories.NoParameters, + }, + parameters: { relativeCsfPaths: ['../examples/SourceParameters.stories'] }, + beforeEach: storyDocsServiceStoryBeforeEach(ParametersStories.NoParameters, { + description: SERVICE_STORY_DESCRIPTION, + }), + play: async ({ canvasElement }) => { + await expect(canvasElement).toHaveTextContent(SERVICE_STORY_DESCRIPTION); + }, +}; + export const DefaultAttached: Story = { parameters: { relativeCsfPaths: ['../examples/Button.stories'], attached: true }, }; diff --git a/code/addons/docs/src/blocks/blocks/Description.tsx b/code/addons/docs/src/blocks/blocks/Description.tsx index 29f3394e8485..f9ef0d6bd1eb 100644 --- a/code/addons/docs/src/blocks/blocks/Description.tsx +++ b/code/addons/docs/src/blocks/blocks/Description.tsx @@ -1,9 +1,12 @@ import type { FC } from 'react'; -import React from 'react'; +import React, { useContext } from 'react'; import { InvalidBlockOfPropError } from 'storybook/internal/preview-errors'; +import { DocsContext } from './DocsContext'; import { Markdown } from './Markdown'; +import { useServiceDocgen } from './use-service-docgen.ts'; +import { useServiceStoryDoc } from './use-service-story-docs.ts'; import type { Of } from './useOf'; import { useOf } from './useOf'; import { withMdxComponentOverride } from './with-mdx-component-override'; @@ -23,10 +26,15 @@ interface DescriptionProps { of?: Of; } -const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): string | null => { +const getDescriptionFromResolvedOf = ( + resolvedOf: ReturnType, + serviceComponentDescription?: string, + storyDocsDescription?: string | null +): string | null => { switch (resolvedOf.type) { case 'story': { - return resolvedOf.story.parameters.docs?.description?.story || null; + const storyDescription = resolvedOf.story.parameters.docs?.description?.story; + return storyDescription !== undefined ? storyDescription : (storyDocsDescription ?? null); } case 'meta': { const { parameters, component } = resolvedOf.preparedMeta; @@ -38,7 +46,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } case 'component': { @@ -50,7 +60,9 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str parameters?.docs?.extractComponentDescription?.(component, { component, parameters, - }) || null + }) || + serviceComponentDescription || + null ); } default: { @@ -61,16 +73,91 @@ const getDescriptionFromResolvedOf = (resolvedOf: ReturnType): str } }; +type ResolvedOf = ReturnType; + +const DescriptionBody: FC<{ + resolvedOf: ResolvedOf; + serviceComponentDescription?: string; + lang?: string; + storyDocsDescription?: string | null; +}> = ({ resolvedOf, serviceComponentDescription, lang, storyDocsDescription }) => { + const markdown = getDescriptionFromResolvedOf( + resolvedOf, + serviceComponentDescription, + storyDocsDescription + ); + + return markdown ? {markdown} : null; +}; + +const DescriptionStoryWithServices: FC<{ + resolvedOf: Extract; +}> = ({ resolvedOf }) => { + const storyDocsDescription = useServiceStoryDoc(resolvedOf.story.id).data?.description; + const lang = resolvedOf.story.parameters.docs?.lang || 'en'; + return ( + + ); +}; + +const DescriptionComponentWithServices: FC<{ + resolvedOf: Extract; + componentId: string; +}> = ({ resolvedOf, componentId }) => { + const serviceComponentDescription = useServiceDocgen(componentId).data?.description || undefined; + const lang = + resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters.docs?.lang || 'en' + : resolvedOf.projectAnnotations.parameters?.docs?.lang || 'en'; + return ( + + ); +}; + const DescriptionImpl: FC = (props) => { const { of } = props; + const context = useContext(DocsContext); if ('of' in props && of === undefined) { throw new InvalidBlockOfPropError(); } const resolvedOf = useOf(of || 'meta'); - const markdown = getDescriptionFromResolvedOf(resolvedOf); - return markdown ? {markdown} : null; + // The docgen service contributes a fallback description, but its two sources need different hooks + // (story-docs by story id, docgen by component id), so each lives in its own child component to + // keep the hook call unconditional. When the feature is off — or a bare `of={Component}` has no + // resolvable component id — render without a service fallback. + if (globalThis.FEATURES?.experimentalDocgenServer) { + if (resolvedOf.type === 'story') { + return ; + } + + const componentId = + resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.componentId + : context.getComponentId(resolvedOf.component); + + if (componentId) { + return ; + } + } + + const parameters = + resolvedOf.type === 'story' + ? resolvedOf.story.parameters + : resolvedOf.type === 'meta' + ? resolvedOf.preparedMeta.parameters + : resolvedOf.projectAnnotations.parameters; + const lang = parameters?.docs?.lang || 'en'; + return ; }; export const Description = withMdxComponentOverride('Description', DescriptionImpl); diff --git a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx index 3cae103afe80..8567c7afb7a2 100644 --- a/code/addons/docs/src/blocks/blocks/DocsContainer.tsx +++ b/code/addons/docs/src/blocks/blocks/DocsContainer.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useMemo } from 'react'; import type { Renderer } from 'storybook/internal/types'; import type { ThemeVars } from 'storybook/theming'; -import { ThemeProvider, ensure as ensureTheme } from 'storybook/theming'; +import { ensure as ensureTheme, ThemeProvider } from 'storybook/theming'; import { DocsPageWrapper } from '../components'; import { TableOfContents } from '../components/TableOfContents'; @@ -28,13 +28,18 @@ export const DocsContainer: FC> = ({ }) => { const slugger = useMemo(() => createDocsSlugger(), []); let toc; + // Language of docs prose (descriptions, ArgTypes description cells, free MDX prose). + let lang; try { const meta = context.resolveOf('meta', ['meta']); - toc = meta.preparedMeta.parameters?.docs?.toc; + const metaParameters = meta.preparedMeta.parameters; + toc = metaParameters?.docs?.toc; + lang = metaParameters?.docs?.lang || 'en'; } catch (err) { // No meta, falling back to project annotations toc = context?.projectAnnotations?.parameters?.docs?.toc; + lang = context?.projectAnnotations?.parameters?.docs?.lang || 'en'; } useEffect(() => { @@ -61,6 +66,7 @@ export const DocsContainer: FC> = ({ { + const docsContext = context.loaded?.docsContext as DocsContextProps | undefined; + invariant(docsContext, 'docsContext is required to mock story-docs for docs block stories'); + + // The docs blocks only consume the story-docs service when this feature is enabled, but it is + // disabled by default in production builds (e.g. Chromatic). Enable it here so the service-backed + // story renders the mocked data instead of falling back to the non-service path. + const previousFeatures = globalThis.FEATURES; + globalThis.FEATURES = { ...previousFeatures, experimentalDocgenServer: true }; + + const payload = createStoryDocsPayload(docsContext, of, data); + unregisterService('core/story-docs'); + const service = registerService(storyDocsServiceDef, { + commands: { + extractStoryDocs: { + handler: (input, ctx) => { + ctx.self.setState((state) => { + state.components[input.id] = payload; + }); + return payload; + }, + }, + extractAllStoryDocs: { + handler: (_input, ctx) => { + ctx.self.setState((state) => { + state.components[payload.id] = payload; + }); + }, + }, + }, + }); + await service.commands.extractStoryDocs({ id: payload.id }); + + return () => { + unregisterService('core/story-docs'); + globalThis.FEATURES = previousFeatures; + }; + }; +} + +const SERVICE_IMPORT = "import { EmptyExample } from './EmptyExample';"; +const SERVICE_SNIPPET = ''; const meta: Meta = { component: Source, @@ -214,6 +292,24 @@ export const Of: Story = { }, }; +export const OfStorySnippetFromStoryDocsService: Story = { + args: { + of: ParametersStories.NoParameters, + }, + beforeEach: storyDocsServiceStoryBeforeEach(ParametersStories.NoParameters, { + import: SERVICE_IMPORT, + snippet: SERVICE_SNIPPET, + }), + play: async ({ canvasElement }) => { + await waitFor(() => { + const text = canvasElement.textContent ?? ''; + expect(text).toContain('EmptyExample'); + expect(text).toContain('something='); + expect(text).not.toContain("const emitted = 'source';"); + }); + }, +}; + export const OfUndefined: Story = { args: { // @ts-expect-error this is supposed to be undefined diff --git a/code/addons/docs/src/blocks/blocks/Source.tsx b/code/addons/docs/src/blocks/blocks/Source.tsx index 2ee7af35c816..37d725e05d52 100644 --- a/code/addons/docs/src/blocks/blocks/Source.tsx +++ b/code/addons/docs/src/blocks/blocks/Source.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import type { ComponentProps, FC } from 'react'; import React, { useContext, useMemo } from 'react'; import { SourceType } from 'storybook/internal/docs-tools'; @@ -11,6 +11,7 @@ import type { DocsContextProps } from './DocsContext'; import { DocsContext } from './DocsContext'; import type { SourceContextProps, SourceItem } from './SourceContainer'; import { SourceContext, UNKNOWN_ARGS_HASH, argsHash } from './SourceContainer'; +import { useServiceStorySnippet } from './use-service-story-docs.ts'; import { useTransformCode } from './useTransformCode'; import { withMdxComponentOverride } from './with-mdx-component-override'; @@ -65,11 +66,13 @@ const getStorySource = ( const useCode = ({ snippet, + serviceSnippet, storyContext, typeFromProps, transformFromProps, }: { snippet: string; + serviceSnippet: string; storyContext: ReturnType; typeFromProps: SourceType; transformFromProps?: SourceProps['transform']; @@ -80,13 +83,14 @@ const useCode = ({ const type = typeFromProps || sourceParameters.type || SourceType.AUTO; + const staticSnippet = serviceSnippet || snippet; const useSnippet = // if user has explicitly set this as dynamic, use snippet type === SourceType.DYNAMIC || // if this is an args story and there's a snippet - (type === SourceType.AUTO && snippet && isArgsStory); + (type === SourceType.AUTO && staticSnippet && isArgsStory); - const code = useSnippet ? snippet : sourceParameters.originalSource || ''; + const code = useSnippet ? staticSnippet : sourceParameters.originalSource || ''; const transformer = transformFromProps ?? sourceParameters.transform; const transformedCode = transformer ? useTransformCode(code, transformer, storyContext) : code; @@ -104,7 +108,8 @@ type PureSourceProps = ComponentProps; export const useSourceProps = ( props: SourceProps, docsContext: DocsContextProps, - sourceContext: SourceContextProps + sourceContext: SourceContextProps, + serviceSnippet = '' ): PureSourceProps => { const { of } = props; @@ -132,6 +137,7 @@ export const useSourceProps = ( const transformedCode = useCode({ snippet: source ? source.code : '', + serviceSnippet, storyContext: { ...storyContext, args: argsForSource }, typeFromProps: props.type as SourceType, transformFromProps: props.transform, @@ -170,15 +176,51 @@ export const useSourceProps = ( }; }; +const SourceWithStoryDocsSnippet: FC< + SourceProps & { + docsContext: DocsContextProps; + sourceContext: SourceContextProps; + storyId: string; + } +> = ({ storyId, docsContext, sourceContext, ...props }) => { + const serviceSnippet = useServiceStorySnippet(storyId).data ?? ''; + const sourceProps = useSourceProps(props, docsContext, sourceContext, serviceSnippet); + return ; +}; + /** * Story source doc block renders source code if provided, or the source for a story if `storyId` is * provided, or the source for the current story if nothing is provided. */ const SourceWithStorySnippet = (props: SourceProps) => { + const { of } = props; const sourceContext = useContext(SourceContext); const docsContext = useContext(DocsContext); - const sourceProps = useSourceProps(props, docsContext, sourceContext); + const story = useMemo(() => { + if (of) { + const resolved = docsContext.resolveOf(of, ['story']); + return resolved.story; + } + try { + return docsContext.storyById(); + } catch { + // You are allowed to use and unattached. + } + }, [docsContext, of]); + + if (globalThis.FEATURES?.experimentalDocgenServer && story?.id) { + return ( + + ); + } + + const sourceProps = useSourceProps(props, docsContext, sourceContext); return ; }; diff --git a/code/addons/docs/src/blocks/blocks/Stories.tsx b/code/addons/docs/src/blocks/blocks/Stories.tsx index 555163f539e6..bd2b9910582c 100644 --- a/code/addons/docs/src/blocks/blocks/Stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Stories.tsx @@ -9,6 +9,7 @@ import { DocsContext } from './DocsContext'; import { DocsStory } from './DocsStory'; import { Heading } from './Heading'; import { withMdxComponentOverride } from './with-mdx-component-override'; +import type { DocsParameters } from '../../types.ts'; interface StoriesProps { title?: ReactElement | string; @@ -35,7 +36,8 @@ const StoriesImpl: FC = ({ title = 'Stories', includePrimary = tru const { componentStories, projectAnnotations, getStoryContext } = useContext(DocsContext); let stories = componentStories(); - const { stories: { filter } = { filter: undefined } } = projectAnnotations.parameters?.docs || {}; + const filter = (projectAnnotations.parameters as DocsParameters | undefined)?.docs?.stories + ?.filter; if (filter) { stories = stories.filter((story) => filter(story, getStoryContext(story))); } diff --git a/code/addons/docs/src/blocks/blocks/Story.stories.tsx b/code/addons/docs/src/blocks/blocks/Story.stories.tsx index 4df1e7f9878b..afbdb2dbbbd3 100644 --- a/code/addons/docs/src/blocks/blocks/Story.stories.tsx +++ b/code/addons/docs/src/blocks/blocks/Story.stories.tsx @@ -66,6 +66,20 @@ export const Inline: Story = { }, }; +export const InlineHtmlLang: Story = { + ...Inline, + args: { + of: StoryParametersStories.HtmlLang, + inline: true, + }, + play: async ({ canvasElement }) => { + await waitFor(() => { + const inner = canvasElement.querySelector('[id$="-inner"]'); + expect(inner).toHaveAttribute('lang', 'fr'); + }); + }, +}; + export const InlineWithHeightProps: Story = { ...Inline, args: { diff --git a/code/addons/docs/src/blocks/blocks/argTypesShared.ts b/code/addons/docs/src/blocks/blocks/argTypesShared.ts new file mode 100644 index 000000000000..79a1301392e2 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/argTypesShared.ts @@ -0,0 +1,86 @@ +import type { Args, Parameters, Renderer, StrictArgTypes } from 'storybook/internal/csf'; +import type { ArgTypesExtractor } from 'storybook/internal/docs-tools'; +import { + getServiceSubcomponentArgTypes, + mergeServiceArgTypes, +} from 'storybook/internal/docs-tools'; + +import { ArgsTableError } from '../components'; +import { useServiceDocgen } from './use-service-docgen.ts'; + +/** Runs the renderer's docgen extractor against a component, throwing when it is unavailable. */ +export function extractComponentArgTypes( + component: Renderer['component'], + parameters: Parameters +): StrictArgTypes { + const { extractArgTypes }: { extractArgTypes: ArgTypesExtractor } = parameters.docs || {}; + if (!extractArgTypes) { + throw new Error(ArgsTableError.ARGS_UNSUPPORTED); + } + return extractArgTypes(component) as StrictArgTypes; +} + +/** Extracts argTypes for each declared subcomponent via the renderer's docgen extractor. */ +export function extractSubcomponentArgTypes( + subcomponents: Record | undefined, + parameters: Parameters +): Record { + return Object.fromEntries( + Object.entries(subcomponents || {}).map(([key, comp]) => [ + key, + extractComponentArgTypes(comp, parameters), + ]) + ); +} + +export type DocgenServiceRows = { + /** The component name reported by the service, used as a fallback table title. */ + serviceComponentName: string; + mainRows: StrictArgTypes; + subcomponentRows: Record; +}; + +/** + * Shared docgen-service recipe for the ArgTypes and Controls blocks behind + * `experimentalDocgenServer`. + * + * Subscribes to the `core/docgen` service for the component's server-extracted argTypes and merges + * them with the locally-prepared `customArgTypes` (the service only carries extracted component + * docgen, so the block works regardless of whether the story rendered). Surfaces the query's + * `isInitialLoading` flag alongside the rows so callers can render a loading skeleton while docgen is + * still resolving (instead of rendering nothing); `rows` stays `null` until a payload arrives. + */ +export function useDocgenServiceRows({ + componentId, + storyId, + parameters, + initialArgs, + customArgTypes, +}: { + componentId: string; + storyId?: string; + parameters?: Parameters; + initialArgs?: Args; + customArgTypes?: StrictArgTypes; +}): { rows: DocgenServiceRows | null; isInitialLoading: boolean } { + const { data: servicePayload, isInitialLoading } = useServiceDocgen(componentId); + + if (!servicePayload) { + return { rows: null, isInitialLoading }; + } + + return { + rows: { + serviceComponentName: servicePayload.name, + mainRows: mergeServiceArgTypes({ + payload: servicePayload, + storyId: storyId ?? componentId, + parameters, + initialArgs, + customArgTypes, + }), + subcomponentRows: getServiceSubcomponentArgTypes(servicePayload), + }, + isInitialLoading, + }; +} diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx b/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx index 0e8f4cab2f7e..350feb6c8825 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocs.tsx @@ -1,6 +1,7 @@ import type { PropsWithChildren } from 'react'; import React, { useRef } from 'react'; +import { deprecate } from 'storybook/internal/client-logger'; import type { ProjectAnnotations, Renderer } from 'storybook/internal/types'; import { composeConfigs } from 'storybook/preview-api'; @@ -27,6 +28,7 @@ export function ExternalDocs({ projectAnnotationsList, children, }: PropsWithChildren>) { + deprecate(`ExternalDocs is deprecated and will be removed in Storybook 11.`); const projectAnnotations = composeConfigs(projectAnnotationsList); const preview = usePreview(projectAnnotations); const docsParameter = { diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx index a153e72968f8..4101689817bd 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContainer.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { deprecate } from 'storybook/internal/client-logger'; import type { Renderer } from 'storybook/internal/types'; import { ThemeProvider, ensure, themes } from 'storybook/theming'; @@ -12,6 +13,7 @@ let preview: ExternalPreview; export const ExternalDocsContainer: React.FC< React.PropsWithChildren<{ projectAnnotations: any }> > = ({ projectAnnotations, children }) => { + deprecate(`ExternalDocsContainer is deprecated and will be removed in Storybook 11.`); if (!preview) { preview = new ExternalPreview(projectAnnotations); } diff --git a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts index 63e02b4acc08..436d28a86f84 100644 --- a/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts +++ b/code/addons/docs/src/blocks/blocks/external/ExternalDocsContext.ts @@ -16,6 +16,11 @@ export class ExternalDocsContext extends DocsContext renderStoryToElement as DocsContextProps['renderStoryToElement'], [] ); + + // External docs are always an author-supplied page (`` passes its children as the + // docs page), so `` / `` should respect the author's story selection + // rather than filtering to `autodocs`-tagged stories. + this.filterByAutodocs = false; } referenceMeta = (metaExports: ModuleExports, attach: boolean) => { diff --git a/code/addons/docs/src/blocks/blocks/mdx.tsx b/code/addons/docs/src/blocks/blocks/mdx.tsx index c54fc07ced62..4339ea8a24e6 100644 --- a/code/addons/docs/src/blocks/blocks/mdx.tsx +++ b/code/addons/docs/src/blocks/blocks/mdx.tsx @@ -2,7 +2,7 @@ import type { FC, MouseEvent, PropsWithChildren, SyntheticEvent } from 'react'; import React, { useContext } from 'react'; import type { SupportedLanguage } from 'storybook/internal/components'; -import { Code, components, nameSpaceClassNames } from 'storybook/internal/components'; +import { Button, Code, components, nameSpaceClassNames } from 'storybook/internal/components'; import { NAVIGATE_URL } from 'storybook/internal/core-events'; import { LinkIcon } from '@storybook/icons'; @@ -143,31 +143,47 @@ const SUPPORTED_MDX_HEADERS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const; const OcticonHeaders = SUPPORTED_MDX_HEADERS.reduce( (acc, headerType) => ({ ...acc, - [headerType]: styled(headerType)({ - '& svg': { - position: 'relative', - top: '-0.1em', - visibility: 'hidden', - }, - '&:hover svg': { - visibility: 'visible', - }, - }), + [headerType]: styled(headerType)({}), }), {} ); -const OcticonAnchor = styled.a( - () => - ({ - float: 'left', - lineHeight: 'inherit', - paddingRight: '10px', - marginLeft: '-24px', - // Allow the theme's text color to override the default link color. - color: 'inherit', - }) as const -); +const OcticonAlignmentWrapper = styled.span({ + display: 'block', + position: 'relative', + '& svg': { + visibility: 'hidden', + }, + '&:hover svg, &:focus-within svg': { + visibility: 'visible', + }, +}); + +const OcticonAnchorWrapper = styled.span({ + // Position the anchor in the heading's left gutter instead of floating it, so the + // Button's dimensions never shift the heading text. The parent header is relatively + // positioned to anchor this. + position: 'absolute', + top: '50%', + right: '100%', + lineHeight: 'inherit', + paddingRight: '8px', + // Increase specificity to avoid being overridden by DocsPage based on + // CSS block load order. + '&&': { + marginTop: -14, // Half the Button's height to center it vertically + }, + // Allow the theme's text color to override the default link color. + color: 'inherit', + '& a': { + color: 'inherit', + textDecoration: 'none', + }, +}); + +const HeaderTitle = styled.span({ + // marginInlineStart: -40, +}); interface HeaderWithOcticonAnchorProps { as: string; @@ -188,21 +204,32 @@ const HeaderWithOcticonAnchor: FC - { - const element = document.getElementById(id); - if (element) { - navigate(context, hash); - } - }} - > - - - {children} + + + + ) => { + event.preventDefault(); + const element = document.getElementById(id); + if (element) { + navigate(context, hash); + } + }} + > + + + + + {children} + ); }; diff --git a/code/addons/docs/src/blocks/blocks/use-query-subscription.ts b/code/addons/docs/src/blocks/blocks/use-query-subscription.ts new file mode 100644 index 000000000000..034c70574377 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-query-subscription.ts @@ -0,0 +1,82 @@ +import { useCallback, useRef } from 'react'; + +import { type Query, type QueryState, seedQueryState } from 'storybook/open-service'; + +import { useSyncExternalStoreShim } from './useSyncExternalStoreShim.ts'; + +/** + * React 16/17-safe subscription to a single open-service query, shared by the preview-side docs + * hooks. + * + * Deliberately does NOT reuse the manager-side `useServiceQuery`: that hook is built on React's + * `useSyncExternalStore`, which only exists in React 18+, while the preview docs blocks must keep + * working on React 16/17. It is instead built on {@link useSyncExternalStoreShim} (the React 16-safe + * port of that hook), adding the open-service specifics: + * + * - the current {@link QueryState} is seeded synchronously during render (via `seedQueryState`, a + * pure `.get()` read wrapped in a synthetic `pending` state) so switching subjects never lags a + * frame, and + * - the subscription re-renders whenever the query emits a new {@link QueryState}. + * + * `cacheKey` must uniquely identify the `input` (a stringifiable identity for the value the query is + * read with); it gates the synchronous re-seed and re-subscription. `input` itself is read through a + * ref so an inline object literal that is structurally stable under one `cacheKey` does not force a + * re-subscribe every render. A `selector` cannot be folded into a string key, so it is tracked by + * reference as a real dependency: changing it re-seeds and re-subscribes so the selected slice can + * never lag behind the current selector. + */ +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput +): QueryState; +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput, + selector: (value: TOutput) => TSelected +): QueryState; +export function useQuerySubscription( + cacheKey: string, + query: Query, + input: TInput, + selector?: (value: TOutput) => TSelected +): QueryState { + const cache = useRef<{ + key: string; + selector: ((value: TOutput) => TSelected) | undefined; + state: QueryState; + }>(undefined); + + if (cache.current?.key !== cacheKey || cache.current.selector !== selector) { + cache.current = { + key: cacheKey, + selector, + state: selector ? seedQueryState(query, input, selector) : seedQueryState(query, input), + }; + } + + const inputRef = useRef(input); + inputRef.current = input; + + // Re-subscribe only when the query, input identity (`cacheKey`), or `selector` changes; `input` + // stays in a ref so its per-render object identity does not re-subscribe under a stable key. + const subscribe = useCallback( + (listener: () => void): (() => void) => { + const onQueryState = (state: QueryState) => { + cache.current = { key: cacheKey, selector, state }; + listener(); + }; + return selector + ? query.subscribe(inputRef.current, selector, onQueryState) + : query.subscribe(inputRef.current, onQueryState); + }, + [query, cacheKey, selector] + ); + + // Pure ref read: the shim calls this during render and to detect emitted changes, so it must not + // recompute the seed (which would re-run the query handler). + const getSnapshot = useCallback((): QueryState => cache.current!.state, []); + + return useSyncExternalStoreShim(subscribe, getSnapshot); +} diff --git a/code/addons/docs/src/blocks/blocks/use-service-docgen.ts b/code/addons/docs/src/blocks/blocks/use-service-docgen.ts new file mode 100644 index 000000000000..b37f9572aa38 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-service-docgen.ts @@ -0,0 +1,24 @@ +import type { DocgenPayload } from 'storybook/internal/types'; + +import { type QueryState } from 'storybook/open-service'; +import { getService } from 'storybook/preview-api'; + +import { useQuerySubscription } from './use-query-subscription.ts'; + +/** + * Subscribes docs blocks to the preview's local `core/docgen` runtime. + * + * Returns the full {@link QueryState} — `data` plus the load lifecycle (`isInitialLoading`, + * `isError`, etc.) — so blocks can show a loading skeleton or error state instead of rendering + * nothing while docgen resolves. `data` mirrors the query's output (a {@link DocgenPayload}, or + * `undefined` when nothing has been extracted for the id yet). + * + * Requires a concrete component id and a registered `core/docgen` service. Callers whose service may + * be absent (e.g. behind `experimentalDocgenServer`) must guard at a parent and conditionally render + * a child that calls this hook. The React 16/17-safe subscription mechanics live in + * {@link useQuerySubscription}. + */ +export function useServiceDocgen(id: string): QueryState { + const service = getService('core/docgen'); + return useQuerySubscription(id, service.queries.docgen, { id }); +} diff --git a/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts b/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts new file mode 100644 index 000000000000..fff7e070abb2 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/use-service-story-docs.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import type { StoryDoc, StoryDocsPayload } from 'storybook/internal/types'; + +import { type QueryState, selectSnippetForStory, selectStoryDoc } from 'storybook/open-service'; +import { getService } from 'storybook/preview-api'; + +import { useQuerySubscription } from './use-query-subscription.ts'; + +/** + * Subscribes docs blocks to one story in the preview's local `core/story-docs` runtime. + * + * Derives the component id from the story id and uses the query's selector subscription so the + * callback only fires when the selected story slice changes — not when a sibling story in the same + * CSF file updates. Returns the full {@link QueryState} — the selected `data` plus the load lifecycle + * (`isInitialLoading`, `isError`, etc.) — so blocks can react to loading and error states. + * + * Requires a concrete story id and a registered `core/story-docs` service. Callers whose service may + * be absent must guard at a parent and conditionally render a child that calls this hook. The React + * 16/17-safe subscription mechanics live in {@link useQuerySubscription}. + */ +export function useServiceStory( + storyId: string, + selector: (payload: StoryDocsPayload | undefined, storyId: string) => TSelected +): QueryState { + const componentId = storyId.split('--')[0]!; + const service = getService('core/story-docs'); + + // Kept stable (selectors are compared by reference on the subscription) so we don't re-subscribe + // every render. + const boundSelector = useCallback( + (payload: StoryDocsPayload | undefined) => selector(payload, storyId), + [selector, storyId] + ); + + // Keyed on `storyId` (not `componentId`): the selected slice is per-story, so switching between + // sibling stories in the same component must re-seed and re-subscribe with the new selector. + return useQuerySubscription( + storyId, + service.queries.storyDocs, + { id: componentId }, + boundSelector + ); +} + +/** Convenience hook for the common case: the story-docs entry for one story. */ +export function useServiceStoryDoc(storyId: string): QueryState { + return useServiceStory(storyId, selectStoryDoc); +} + +/** Convenience hook returning one story's display snippet (with its CSF import block prepended). */ +export function useServiceStorySnippet(storyId: string): QueryState { + return useServiceStory(storyId, selectSnippetForStory); +} diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx index f657c2f3dc63..c98caaa5b65b 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.test.tsx @@ -19,8 +19,13 @@ const stories: Record> = { story4: { name: 'Story Four', tags: [] }, }; -const createMockContext = (storyList: PreparedStory[]) => ({ +const createMockContext = ( + storyList: PreparedStory[], + overrides: Partial = {} +) => ({ componentStories: vi.fn(() => storyList), + filterByAutodocs: true, + ...overrides, }); const Wrapper: FC }>> = ({ @@ -28,7 +33,7 @@ const Wrapper: FC }>> = ( context, }) => {children}; -describe('usePrimaryStory', () => { +describe('usePrimaryStory — autodocs page (filterByAutodocs: true)', () => { it('ignores !autodocs stories', () => { const mockContext = createMockContext([ stories.story1, @@ -49,7 +54,7 @@ describe('usePrimaryStory', () => { expect(result.current?.name).toBe('Story Two'); }); - it('returns undefined if no story has "autodocs" tag', () => { + it('returns undefined when no story has the autodocs tag', () => { const mockContext = createMockContext([stories.story1, stories.story4] as PreparedStory[]); const { result } = renderHook(() => usePrimaryStory(), { wrapper: ({ children }) => {children}, @@ -65,3 +70,16 @@ describe('usePrimaryStory', () => { expect(result.current).toBeUndefined(); }); }); + +describe('usePrimaryStory — MDX / custom page (filterByAutodocs: false)', () => { + it('returns the first story regardless of autodocs tag', () => { + const mockContext = createMockContext( + [stories.story1, stories.story2, stories.story3] as PreparedStory[], + { filterByAutodocs: false } + ); + const { result } = renderHook(() => usePrimaryStory(), { + wrapper: ({ children }) => {children}, + }); + expect(result.current?.name).toBe('Story One'); + }); +}); diff --git a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts index 75d085a69b17..d2b0491c5b58 100644 --- a/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts +++ b/code/addons/docs/src/blocks/blocks/usePrimaryStory.ts @@ -6,11 +6,15 @@ import type { PreparedStory } from 'storybook/internal/types'; import { DocsContext } from './DocsContext'; /** - * A hook to get the primary story for the current component's doc page. It defines the primary - * story as the first story that includes the 'autodocs' tag + * Returns the primary story for the current docs page. Autodocs pages pick the first story tagged + * `autodocs`; MDX pages pick the first story regardless of tag (driven by + * `DocsContext.filterByAutodocs`, set by the docs render based on the entry type). */ export const usePrimaryStory = (): PreparedStory | undefined => { const context = useContext(DocsContext); const stories = context.componentStories(); + if (context.filterByAutodocs === false) { + return stories[0]; + } return stories.find((story) => story.tags.includes(Tag.AUTODOCS)); }; diff --git a/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts b/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts new file mode 100644 index 000000000000..0964ca9705b9 --- /dev/null +++ b/code/addons/docs/src/blocks/blocks/useSyncExternalStoreShim.ts @@ -0,0 +1,67 @@ +import { useEffect, useLayoutEffect, useState } from 'react'; + +/** + * Inlined fallback for React's `useSyncExternalStore`. + * + * `addon-docs` still supports React 16.8 and 17 (see the `react` range in `package.json`), and + * `useSyncExternalStore` only exists from React 18 onwards. This is a faithful port of the official + * `use-sync-external-store/shim` fallback, which is safe on React 16/17 because those versions render + * synchronously and therefore can't tear (the tearing problem the real hook guards against only + * occurs with concurrent rendering, which doesn't exist before React 18). + * + * The snapshot is recomputed on every render and returned directly, so the value is always current + * during render; the `useState` updater is used solely as a force-re-render mechanism when the store + * emits a change. The layout effect re-checks the snapshot to catch a store mutation that happened + * between render and commit. + * + * TODO: Delete this shim and go back to importing `useSyncExternalStore` from `react` once we drop + * support for React < 18. + */ +export function useSyncExternalStoreShim( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => T +): T { + const value = getSnapshot(); + // `inst` is a stable mutable container that is never reassigned, so it is intentionally omitted + // from the effect dependency arrays below (matching React's upstream shim). The dependency arrays + // are deliberately `[subscribe, value, getSnapshot]` and `[subscribe]` to preserve the exact + // re-subscription semantics of `useSyncExternalStore`. + const [{ inst }, forceUpdate] = useState({ inst: { value, getSnapshot } }); + + useLayoutEffect(() => { + inst.value = value; + inst.getSnapshot = getSnapshot; + + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe, value, getSnapshot]); + + useEffect(() => { + // Re-check on subscribe in case the store changed before the subscription was set up. + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + + return subscribe(() => { + if (didSnapshotChange(inst)) { + forceUpdate({ inst }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subscribe]); + + return value; +} + +function didSnapshotChange(inst: { value: T; getSnapshot: () => T }): boolean { + const latestGetSnapshot = inst.getSnapshot; + const prevValue = inst.value; + try { + const nextValue = latestGetSnapshot(); + return !Object.is(prevValue, nextValue); + } catch { + return true; + } +} diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx index 97d3013b4bd8..1cc3b2e697e1 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgControl.tsx @@ -20,8 +20,9 @@ export interface ArgControlProps { row: ArgType; arg: any; updateArgs: (args: Args) => void; - isHovered: boolean; + isRequired: boolean; storyId?: string; + controlsId?: string; } const Controls: Record> = { @@ -44,7 +45,14 @@ const Controls: Record> = { const NoControl = () => <>->; -export const ArgControl: FC = ({ row, arg, updateArgs, isHovered, storyId }) => { +export const ArgControl: FC = ({ + row, + arg, + updateArgs, + isRequired, + storyId, + controlsId, +}) => { const { key, control } = row; const [isFocused, setFocused] = useState(false); @@ -71,16 +79,26 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere if (!control || control.disable) { const canBeSetup = control?.disable !== true && row?.type?.name !== 'function'; - return isHovered && canBeSetup ? ( - - Setup controls - - ) : ( - + if (!canBeSetup) { + return ; + } + // Both nodes are always rendered; the parent row toggles their visibility with CSS on + // :hover and :focus-within, so the link stays reachable for keyboard users. + return ( + <> + + + Setup controls + + + + + + > ); } // row.name is a display name and not a suitable DOM input id or name - i might contain whitespace etc. @@ -88,8 +106,10 @@ export const ArgControl: FC = ({ row, arg, updateArgs, isHovere const props = { name: key, storyId, + controlsId, argType: row, value: boxedValue.value, + required: isRequired, onChange, onBlur, onFocus, diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx index 83ad20c476ac..d21e05930587 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgRow.tsx @@ -1,12 +1,12 @@ import type { FC } from 'react'; -import React, { useState } from 'react'; +import React from 'react'; import { codeCommon } from 'storybook/internal/components'; import Markdown from 'markdown-to-jsx'; import { transparentize } from 'polished'; import type { CSSObject } from 'storybook/theming'; -import { styled } from 'storybook/theming'; +import { srOnlyStyles, srOnlyUnsetStyles, styled } from 'storybook/theming'; import type { ArgControlProps } from './ArgControl'; import { ArgControl } from './ArgControl'; @@ -22,6 +22,8 @@ interface ArgRowProps { expandable?: boolean; initialExpandedArgs?: boolean; storyId?: string; + controlsId?: string; + docsLang?: string; } const Name = styled.span({ fontWeight: 'bold' }); @@ -79,6 +81,22 @@ const StyledTd = styled.td<{ expandable: boolean }>(({ expandable }) => ({ paddingLeft: expandable ? '40px !important' : '20px !important', })); +// When a row has no usable control, the "Setup controls" link is hidden and a placeholder dash is +// shown. Hovering or focusing within the row swaps them, so keyboard users reach the link too. +const StyledTr = styled.tr({ + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyStyles, + }, + '&:hover, &:focus-within': { + [`span.sbdocs-argcontrol-setup`]: { + ...srOnlyUnsetStyles, + }, + [`span.sbdocs-argcontrol-placeholder`]: { + ...srOnlyStyles, + }, + }, +}); + const toSummary = (value: any) => { if (!value) { return value; @@ -88,8 +106,7 @@ const toSummary = (value: any) => { }; export const ArgRow: FC = (props) => { - const [isHovered, setIsHovered] = useState(false); - const { row, updateArgs, compact, expandable, initialExpandedArgs } = props; + const { row, updateArgs, compact, expandable, initialExpandedArgs, docsLang } = props; const { name, description } = row; const table = (row.table || {}) as TableAnnotation; const type = table.type || toSummary(row.type); @@ -98,15 +115,19 @@ export const ArgRow: FC = (props) => { const hasDescription = description != null && description !== ''; return ( - setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + {name} - {required ? * : null} + {required ? ( + + * + + ) : null} {compact ? null : ( {hasDescription && ( - + {description} )} @@ -131,9 +152,9 @@ export const ArgRow: FC = (props) => { )} {updateArgs ? ( - + ) : null} - + ); }; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx new file mode 100644 index 000000000000..71bacd19bbf1 --- /dev/null +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.lang.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import React from 'react'; + +import { ThemeProvider, convert, themes } from 'storybook/theming'; + +import * as ArgRow from './ArgRow.stories'; +import { ArgsTable } from './ArgsTable'; + +const renderTable = (docsLang?: string) => + render( + + {/* Wrap in a non-en region to prove chrome re-asserts English */} + + + + + ); + +describe('ArgsTable chrome stays English', () => { + afterEach(() => cleanup()); + + it('declares English on the table so the chrome stays English under a non-en region', () => { + const { container } = renderTable(); + expect(container.querySelector('.docblock-argstable')?.getAttribute('lang')).toBe('en'); + }); + + it('keeps the arg-name cell announced in English', () => { + const { container } = renderTable(); + const nameCell = container.querySelector('tbody tr td:first-of-type'); + // The cell inherits English from the table wrapper rather than declaring its own lang. + expect(nameCell?.closest('[lang]')?.getAttribute('lang')).toBe('en'); + }); + + it('languages argument descriptions with docs.lang', () => { + const { container } = renderTable('de'); + const description = container.querySelector('tbody tr td:nth-of-type(2) > div'); + expect(description?.getAttribute('lang')).toBe('de'); + }); +}); diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx index e176cb564742..9986aabb1220 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.stories.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; +import { expect, within } from 'storybook/test'; import { styled } from 'storybook/theming'; import * as ArgRow from './ArgRow.stories'; @@ -156,11 +157,34 @@ export const Error = { }, }; -export const Empty = { - args: {}, +const expectEmptyState = async (canvasElement: HTMLElement) => { + const canvas = within(canvasElement); + + await expect(await canvas.findByText('This story has no controls')).toBeVisible(); + await expect( + await canvas.findByText(/Storybook couldn't find or generate any controls for this story/i) + ).toBeVisible(); + const learnMoreLink = await canvas.findByRole('link', { + name: /Read docs/i, + }); + + await expect(learnMoreLink).toBeVisible(); + await expect(learnMoreLink).toHaveAttribute( + 'href', + 'https://storybook.js.org/docs/essentials/controls?ref=ui' + ); +}; + +export const Empty: Story = { + args: { + rows: {}, + }, parameters: { layout: 'centered', }, + play: async ({ canvasElement }) => { + await expectEmptyState(canvasElement); + }, }; export const EmptyInsideAddonPanel: Story = { @@ -171,6 +195,9 @@ export const EmptyInsideAddonPanel: Story = { parameters: { layout: 'centered', }, + play: async ({ canvasElement }) => { + await expectEmptyState(canvasElement); + }, }; export const WithDefaultExpandedArgs = { diff --git a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx index 11e0325d6460..6b142951e62d 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/ArgsTable.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { once } from 'storybook/internal/client-logger'; import { Button, Link, ResetWrapper } from 'storybook/internal/components'; @@ -216,6 +216,8 @@ export interface ArgsTableOptionProps { isLoading?: boolean; sort?: SortType; storyId?: string; + controlsId?: string; + docsLang?: string; } interface ArgsTableDataProps { rows: ArgTypes; @@ -340,8 +342,29 @@ export const ArgsTable: FC = (props) => { sort = 'none', isLoading, storyId, + controlsId, + docsLang, } = props; + const { rows, args, globals } = + 'rows' in props ? props : { rows: undefined, args: undefined, globals: undefined }; + + const isResettingRef = useRef(false); + const [isResetting, setIsResetting] = useState(false); + + useEffect(() => { + isResettingRef.current = false; + setIsResetting(false); + }, [args]); + + const handleResetClick = useCallback(() => { + if (!isResettingRef.current && resetArgs) { + isResettingRef.current = true; + setIsResetting(true); + resetArgs(); + } + }, [resetArgs]); + if ('error' in props) { const { error } = props; return ( @@ -362,9 +385,6 @@ export const ArgsTable: FC = (props) => { if (isLoading) { return ; } - - const { rows, args, globals } = - 'rows' in props ? props : { rows: undefined, args: undefined, globals: undefined }; const groups: Sections = groupRows( pickBy( rows || {}, @@ -393,7 +413,14 @@ export const ArgsTable: FC = (props) => { } const expandable = Object.keys(groups.sections).length > 0; - const common = { updateArgs, compact, inAddonPanel, initialExpandedArgs, storyId }; + const common = { + updateArgs, + compact, + inAddonPanel, + initialExpandedArgs, + storyId, + controlsId, + }; return ( @@ -403,8 +430,10 @@ export const ArgsTable: FC = (props) => { resetArgs()} - ariaLabel="Reset controls" + onClick={handleResetClick} + disabled={isResetting} + ariaLabel={isResetting ? 'Resetting controls...' : 'Reset controls'} + lang="en" > @@ -414,6 +443,7 @@ export const ArgsTable: FC = (props) => { @@ -439,7 +469,13 @@ export const ArgsTable: FC = (props) => { {groups.ungrouped.map((row) => ( - + ))} {Object.entries(groups.ungroupedSubsections).map(([subcategory, subsection]) => ( diff --git a/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx b/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx index b8e0b8fbd11e..a8b6df56f4b0 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/Empty.tsx @@ -51,39 +51,23 @@ export const Empty: FC = ({ inAddonPanel }) => { return ( - Controls give you an easy to use interface to test your components. Set your story args - and you'll see controls appearing here automatically. + Storybook couldn't find or generate any controls for this story. Define{' '} + args or argTypes, or configure docgen to let Storybook + generate controls automatically. > } footer={ - {inAddonPanel && ( - <> - - Read docs - - > - )} - {!inAddonPanel && ( - - Learn how to set that up - - )} + + Read docs + } /> diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx index 2b8fb49dc55d..3305688a5e6c 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.stories.tsx @@ -1,10 +1,17 @@ +import React, { useState } from 'react'; + import { TabsView } from 'storybook/internal/components'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; + +import * as ArgRow from './ArgRow.stories'; import { ArgsTable } from './ArgsTable'; import { Compact, Normal, Sections } from './ArgsTable.stories'; +import type { TabbedArgsTableProps } from './TabbedArgsTable'; import { TabbedArgsTable } from './TabbedArgsTable'; +import type { Args } from './types'; const meta = { component: TabbedArgsTable, @@ -57,3 +64,53 @@ export const WithContentAround: StoryObj = { > ), }; + +/** + * When multiple tabs are shown, editing a control on the first tab should not remount the input and + * drop focus. This verifies the fix for https://github.com/storybookjs/storybook/issues/29028 + */ +export const RetainControlFocusWithTabs: StoryObj = { + args: { + tabs: { + Main: { + rows: { + someString: ArgRow.String.args.row, + }, + }, + Other: { + rows: { + numberType: ArgRow.Number.args.row, + }, + }, + }, + args: { someString: 'hello' }, + }, + render: function Render(props) { + const [storyArgs, setStoryArgs] = useState({ someString: 'hello' }); + return ( + setStoryArgs((prev) => ({ ...prev, ...updated }))} + /> + ); + }, + beforeEach: async ({ canvasElement }) => { + return async () => { + const canvas = within(canvasElement); + const input = canvas.queryByDisplayValue('hellox') ?? canvas.queryByDisplayValue('hello'); + if (!input) { + return; + } + await userEvent.clear(input); + await userEvent.type(input, 'hello'); + }; + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = await canvas.findByDisplayValue('hello'); + await userEvent.click(input); + await userEvent.type(input, 'x'); + await expect(document.activeElement).toBe(input); + }, +}; diff --git a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx index 3fdc43484256..7d75120d7b83 100644 --- a/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx +++ b/code/addons/docs/src/blocks/components/ArgsTable/TabbedArgsTable.tsx @@ -25,20 +25,19 @@ export const TabbedArgsTable: FC = ({ tabs, ...props }) => return ; } - const tabsFromEntries = entries.map(([label, table], index) => ({ - id: `prop_table_div_${label}`, - title: label, - children: () => { - /** - * The first tab is the main component, controllable if in the Controls block All other tabs - * are subcomponents, never controllable, so we filter out the props indicating - * controllability Essentially all subcomponents always behave like ArgTypes, never Controls - */ - const argsTableProps = index === 0 ? props : { sort: props.sort }; - - return ; - }, - })); + const tabsFromEntries = entries.map(([label, table], index) => { + // The first tab is the main component, controllable if in the Controls block. All other tabs + // are subcomponents, never controllable, so we filter out the props indicating + // controllability. Pass a stable element (not an inline function component) so React + // reconciles the tab panel instead of remounting it on every args update. + const argsTableProps = index === 0 ? props : { sort: props.sort, docsLang: props.docsLang }; + + return { + id: `prop_table_div_${label}`, + title: label, + children: , + }; + }); return ; }; diff --git a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx index f46cb44b5bb0..cef070b0ff88 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.stories.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.stories.tsx @@ -1,6 +1,7 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import React from 'react'; +import { expect, within } from 'storybook/test'; import { Global, css } from 'storybook/theming'; import { ArgsTable, Source } from '.'; @@ -90,6 +91,20 @@ export const Markdown = () => ( ); +export const ForwardsLang = { + name: 'Forwards lang', + render: () => ( + + Sprache + + + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const content = within(canvasElement).getByText('Sprache').closest('.sbdocs-content'); + await expect(content).toHaveAttribute('lang', 'de'); + }, +}; + export const Html = { name: 'HTML', render: () => ( diff --git a/code/addons/docs/src/blocks/components/DocsPage.tsx b/code/addons/docs/src/blocks/components/DocsPage.tsx index ac5576ed7d58..24a00590cfbc 100644 --- a/code/addons/docs/src/blocks/components/DocsPage.tsx +++ b/code/addons/docs/src/blocks/components/DocsPage.tsx @@ -110,6 +110,7 @@ export const DocsContent = styled.div(({ theme }) => { maxWidth: 1000, width: '100%', minWidth: 0, + flex: 1, [toGlobalSelector('a')]: { ...reset, fontSize: 'inherit', @@ -440,7 +441,7 @@ export const DocsWrapper = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'row-reverse', justifyContent: 'center', - padding: '4rem 20px', + padding: '4rem 40px', minHeight: '100vh', boxSizing: 'border-box', gap: '3rem', @@ -451,11 +452,14 @@ export const DocsWrapper = styled.div(({ theme }) => ({ interface DocsPageWrapperProps { children?: React.ReactNode; toc?: React.ReactNode; + lang?: string; } -export const DocsPageWrapper: FC = ({ children, toc }) => ( +export const DocsPageWrapper: FC = ({ children, toc, lang = 'en' }) => ( {toc} - {children} + + {children} + ); diff --git a/code/addons/docs/src/blocks/components/Preview.tsx b/code/addons/docs/src/blocks/components/Preview.tsx index c26c374fcff3..9263da5de080 100644 --- a/code/addons/docs/src/blocks/components/Preview.tsx +++ b/code/addons/docs/src/blocks/components/Preview.tsx @@ -2,8 +2,8 @@ import type { FC, PropsWithChildren, ReactElement, ReactNode } from 'react'; import React, { Children, useCallback, useContext, useMemo, useState } from 'react'; import { logger } from 'storybook/internal/client-logger'; -import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import type { ActionItem } from 'storybook/internal/components'; +import { Bar, Button, ToggleButton, Zoom } from 'storybook/internal/components'; import { CopyIcon, MarkupIcon } from '@storybook/icons'; @@ -232,6 +232,7 @@ export const Preview: FC = ({ {hasSourceError && ( = ({ {hasValidSource && ( <> = ({ > {expanded ? 'Hide code' : 'Show code'} - + {copied ?? 'Copy code'} > )} - {additionalActionItems.map(({ title, className, onClick, disabled }, index: number) => ( - - {title} - - ))} + {additionalActionItems.map( + ({ title, ariaLabel, className, onClick, disabled }, index: number) => ( + + {title} + + ) + )} )} > diff --git a/code/addons/docs/src/blocks/components/Story.tsx b/code/addons/docs/src/blocks/components/Story.tsx index 144d14445fba..bdb153d4162b 100644 --- a/code/addons/docs/src/blocks/components/Story.tsx +++ b/code/addons/docs/src/blocks/components/Story.tsx @@ -82,7 +82,12 @@ const InlineStory: FunctionComponent = (props) => { )} { min-height: ${height}; transform: translateZ(0); overflow: auto }`} ) : null} {showLoader && } - + > ); }; diff --git a/code/addons/docs/src/blocks/components/TableOfContents.tsx b/code/addons/docs/src/blocks/components/TableOfContents.tsx index 75ff1364f242..9c5cd0102ef7 100644 --- a/code/addons/docs/src/blocks/components/TableOfContents.tsx +++ b/code/addons/docs/src/blocks/components/TableOfContents.tsx @@ -1,5 +1,5 @@ -import React, { useEffect } from 'react'; import type { FC, ReactElement } from 'react'; +import React, { useEffect } from 'react'; import type { Channel } from 'storybook/internal/channels'; import { NAVIGATE_URL } from 'storybook/internal/core-events'; @@ -145,7 +145,12 @@ const Title: FC<{ // General case. if (typeof title === 'string' || !title) { return ( - + {title || 'Table of contents'} ); diff --git a/code/addons/docs/src/blocks/components/Toolbar.tsx b/code/addons/docs/src/blocks/components/Toolbar.tsx index a9faf0be1280..68470d7f4f55 100644 --- a/code/addons/docs/src/blocks/components/Toolbar.tsx +++ b/code/addons/docs/src/blocks/components/Toolbar.tsx @@ -65,7 +65,11 @@ export const Toolbar: FC = ({ onReloadStory, ...rest }) => ( - + {isLoading ? ( [1, 2, 3].map((key) => ) diff --git a/code/addons/docs/src/blocks/controls/Boolean.tsx b/code/addons/docs/src/blocks/controls/Boolean.tsx index ee89ab44d8cf..861d955d62bd 100644 --- a/code/addons/docs/src/blocks/controls/Boolean.tsx +++ b/code/addons/docs/src/blocks/controls/Boolean.tsx @@ -123,11 +123,13 @@ export type BooleanProps = ControlProps & BooleanConfig; export const BooleanControl: FC = ({ name, storyId, + controlsId, value, onChange, onBlur, onFocus, argType, + required, }) => { const onSetFalse = useCallback(() => onChange(false), [onChange]); const readonly = !!argType?.table?.readonly; @@ -137,7 +139,7 @@ export const BooleanControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onSetFalse} disabled={readonly} > @@ -145,7 +147,7 @@ export const BooleanControl: FC = ({ ); } - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const parsedValue = typeof value === 'string' ? parse(value) : value; @@ -158,6 +160,7 @@ export const BooleanControl: FC = ({ checked={parsedValue} role="switch" disabled={readonly} + aria-required={required || undefined} {...{ name, onBlur, onFocus }} /> False diff --git a/code/addons/docs/src/blocks/controls/Color.tsx b/code/addons/docs/src/blocks/controls/Color.tsx index b109004c2e63..8702bb2072b1 100644 --- a/code/addons/docs/src/blocks/controls/Color.tsx +++ b/code/addons/docs/src/blocks/controls/Color.tsx @@ -367,6 +367,7 @@ export type ColorControlProps = ControlProps & ColorConfig; export const ColorControl: FC = ({ name, storyId, + controlsId, value: initialValue, onChange, onFocus, @@ -375,6 +376,7 @@ export const ColorControl: FC = ({ maxPresetColors, startOpen = false, argType, + required, }) => { const debouncedOnChange = useCallback(debounce(onChange, 200), [onChange]); const { value, realValue, updateValue, color, colorSpace, cycleColorSpace } = useColorInput( @@ -385,7 +387,7 @@ export const ColorControl: FC = ({ const Picker = ColorPicker[colorSpace]; const readOnly = !!argType?.table?.readonly; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( @@ -398,6 +400,7 @@ export const ColorControl: FC = ({ onChange={(e: ChangeEvent) => updateValue(e.target.value)} onFocus={(e: FocusEvent) => e.target.select()} readOnly={readOnly} + aria-required={required || undefined} placeholder="Choose color..." /> & DateConfig; export const DateControl: FC = ({ name, storyId, + controlsId, value, onChange, onFocus, onBlur, argType, + required, }) => { const [valid, setValid] = useState(true); const dateRef = useRef(); @@ -122,7 +124,7 @@ export const DateControl: FC = ({ setValid(!!time); }; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( @@ -137,6 +139,7 @@ export const DateControl: FC = ({ id={`${controlId}-date`} name={`${controlId}-date`} readOnly={readonly} + aria-required={required || undefined} onChange={onDateChange} {...{ onFocus, onBlur }} /> @@ -150,6 +153,7 @@ export const DateControl: FC = ({ ref={timeRef as RefObject} onChange={onTimeChange} readOnly={readonly} + aria-required={required || undefined} {...{ onFocus, onBlur }} /> {!valid ? invalid : null} diff --git a/code/addons/docs/src/blocks/controls/Files.tsx b/code/addons/docs/src/blocks/controls/Files.tsx index 85f2d839701a..d0a6970dd060 100644 --- a/code/addons/docs/src/blocks/controls/Files.tsx +++ b/code/addons/docs/src/blocks/controls/Files.tsx @@ -41,9 +41,11 @@ export const FilesControl: FC = ({ onChange, name, storyId, + controlsId, accept = 'image/*', value, argType, + required, }) => { const inputElement = useRef(null); const readonly = argType?.control?.readOnly; @@ -64,7 +66,7 @@ export const FilesControl: FC = ({ } }, [value, name]); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( <> @@ -80,6 +82,7 @@ export const FilesControl: FC = ({ disabled={readonly} onChange={handleFileChange} accept={accept} + aria-required={required || undefined} size="flex" /> > diff --git a/code/addons/docs/src/blocks/controls/Number.tsx b/code/addons/docs/src/blocks/controls/Number.tsx index a3f33db74de7..2705dcccd61e 100644 --- a/code/addons/docs/src/blocks/controls/Number.tsx +++ b/code/addons/docs/src/blocks/controls/Number.tsx @@ -28,6 +28,7 @@ const FormInput = styled(Form.Input)(({ theme }) => ({ export const NumberControl: FC = ({ name, storyId, + controlsId, value, onChange, min, @@ -36,6 +37,7 @@ export const NumberControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const [inputValue, setInputValue] = useState(typeof value === 'number' ? value : ''); const [forceVisible, setForceVisible] = useState(false); @@ -103,7 +105,7 @@ export const NumberControl: FC = ({ ariaLabel={false} variant="outline" size="medium" - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onForceVisible} disabled={readonly} > @@ -116,7 +118,7 @@ export const NumberControl: FC = ({ = ({ value={inputValue} valid={parseError ? 'error' : undefined} autoFocus={forceVisible} + aria-required={required || undefined} readOnly={readonly} {...{ name, min, max, step, onFocus, onBlur }} /> diff --git a/code/addons/docs/src/blocks/controls/Object.stories.tsx b/code/addons/docs/src/blocks/controls/Object.stories.tsx index 50146d080f31..2476f334e35f 100644 --- a/code/addons/docs/src/blocks/controls/Object.stories.tsx +++ b/code/addons/docs/src/blocks/controls/Object.stories.tsx @@ -1,6 +1,8 @@ +import React, { useEffect, useState } from 'react'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { expect, fn, waitFor } from 'storybook/test'; import { ObjectControl } from './Object'; @@ -64,6 +66,34 @@ export const Undefined: Story = { }, }; +export const DelayedObject: Story = { + render: (args) => { + const [value, setValue] = useState(undefined); + + useEffect(() => { + setTimeout(() => { + setValue({ + name: 'Michael', + nested: { someBool: true, someNumber: 22 }, + }); + }, 1_000); + }, []); + + return ; + }, + parameters: { + withRawArg: false, + }, + play: async ({ canvas }) => { + await canvas.findByText('"Michael"'); + await waitFor(() => { + expect( + canvas.queryByRole('textbox', { name: 'Edit object as JSON' }) + ).not.toBeInTheDocument(); + }); + }, +}; + class Person { constructor( public firstName: string, diff --git a/code/addons/docs/src/blocks/controls/Object.tsx b/code/addons/docs/src/blocks/controls/Object.tsx index a724dfb9d0a7..98dcedabf08c 100644 --- a/code/addons/docs/src/blocks/controls/Object.tsx +++ b/code/addons/docs/src/blocks/controls/Object.tsx @@ -6,7 +6,7 @@ import { Button, Form, ToggleButton } from 'storybook/internal/components'; import { AddIcon, EditIcon, SubtractIcon } from '@storybook/icons'; import { cloneDeep } from 'es-toolkit/object'; -import { styled, useTheme } from 'storybook/theming'; +import { styled } from 'storybook/theming'; import { getControlId, getControlSetterButtonId } from './helpers'; import { JsonTree } from './react-editable-json-tree'; @@ -163,11 +163,19 @@ const selectValue = (event: SyntheticEvent) => { export type ObjectProps = ControlProps & ObjectConfig; -export const ObjectControl: FC = ({ name, storyId, value, onChange, argType }) => { - const theme = useTheme(); +export const ObjectControl: FC = ({ + name, + storyId, + controlsId, + value, + onChange, + argType, + required, +}) => { const data = useMemo(() => value && cloneDeep(value), [value]); const hasData = data !== null && data !== undefined; const [showRaw, setShowRaw] = useState(!hasData); + const hadDataRef = useRef(hasData); const [parseError, setParseError] = useState(null); const readonly = !!argType?.table?.readonly; @@ -191,6 +199,13 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, setForceVisible(true); }, [onChange, setForceVisible]); + useEffect(() => { + if (!hadDataRef.current && hasData && showRaw && !forceVisible) { + setShowRaw(false); + } + hadDataRef.current = hasData; + }, [forceVisible, hasData, showRaw]); + const htmlElRef = useRef(null); useEffect(() => { if (forceVisible && htmlElRef.current) { @@ -208,7 +223,7 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, Set object @@ -216,20 +231,27 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, ); } + const rawInputId = getControlId(name, storyId, controlsId); const rawJSONForm = ( - ) => updateRaw(event.target.value)} - placeholder="Edit JSON string..." - autoFocus={forceVisible} - valid={parseError ? 'error' : undefined} - readOnly={readonly} - /> + <> + + Edit {name} as JSON + + ) => updateRaw(event.target.value)} + placeholder="Edit JSON string..." + autoFocus={forceVisible} + valid={parseError ? 'error' : undefined} + readOnly={readonly} + aria-required={required || undefined} + /> + > ); const isObjectOrArray = @@ -276,7 +298,7 @@ export const ObjectControl: FC = ({ name, storyId, value, onChange, } - inputElement={(_: any, __: any, ___: any, key: string) => + inputElement={(_: unknown, __: unknown, ___: unknown, key: string) => key ? : } fallback={rawJSONForm} diff --git a/code/addons/docs/src/blocks/controls/Range.tsx b/code/addons/docs/src/blocks/controls/Range.tsx index 61878d0fe909..983999ac08fb 100644 --- a/code/addons/docs/src/blocks/controls/Range.tsx +++ b/code/addons/docs/src/blocks/controls/Range.tsx @@ -178,6 +178,7 @@ function getNumberOfDecimalPlaces(number: number) { export const RangeControl: FC = ({ name, storyId, + controlsId, value, onChange, min = 0, @@ -186,6 +187,7 @@ export const RangeControl: FC = ({ onBlur, onFocus, argType, + required, }) => { const handleChange = (event: ChangeEvent) => { onChange(parse(event.target.value)); @@ -195,7 +197,7 @@ export const RangeControl: FC = ({ const numberOFDecimalsPlaces = useMemo(() => getNumberOfDecimalPlaces(step), [step]); const readonly = !!argType?.table?.readonly; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( @@ -208,6 +210,7 @@ export const RangeControl: FC = ({ type="range" disabled={readonly} onChange={handleChange} + aria-required={required || undefined} {...{ name, min, max, step, onFocus, onBlur }} value={value ?? min} /> diff --git a/code/addons/docs/src/blocks/controls/Text.tsx b/code/addons/docs/src/blocks/controls/Text.tsx index 4723e9e5de68..6f6ec2280e5a 100644 --- a/code/addons/docs/src/blocks/controls/Text.tsx +++ b/code/addons/docs/src/blocks/controls/Text.tsx @@ -23,12 +23,14 @@ const MaxLength = styled.div<{ isMaxed: boolean }>(({ isMaxed }) => ({ export const TextControl: FC = ({ name, storyId, + controlsId, value, onChange, onFocus, onBlur, maxLength, argType, + required, }) => { const handleChange = (event: ChangeEvent) => { onChange(event.target.value); @@ -50,7 +52,7 @@ export const TextControl: FC = ({ variant="outline" size="medium" disabled={readonly} - id={getControlSetterButtonId(name, storyId)} + id={getControlSetterButtonId(name, storyId, controlsId)} onClick={onForceVisible} > Set string @@ -63,13 +65,14 @@ export const TextControl: FC = ({ return ( diff --git a/code/addons/docs/src/blocks/controls/helpers.test.ts b/code/addons/docs/src/blocks/controls/helpers.test.ts index b5661e024238..7963639b9858 100644 --- a/code/addons/docs/src/blocks/controls/helpers.test.ts +++ b/code/addons/docs/src/blocks/controls/helpers.test.ts @@ -15,6 +15,14 @@ describe('getControlId', () => { it('includes storyId when provided', () => { expect(getControlId('some-id', 'story--name')).toBe('control-story--name-some-id'); }); + + it('includes controlsId when provided', () => { + expect(getControlId('some-id', undefined, 'r1')).toBe('control-r1-some-id'); + }); + + it('includes both controlsId and storyId when provided', () => { + expect(getControlId('some-id', 'story--name', 'r1')).toBe('control-r1-story--name-some-id'); + }); }); describe('getControlSetterButtonId', () => { @@ -30,4 +38,14 @@ describe('getControlSetterButtonId', () => { it('includes storyId when provided', () => { expect(getControlSetterButtonId('some-id', 'story--name')).toBe('set-story--name-some-id'); }); + + it('includes controlsId when provided', () => { + expect(getControlSetterButtonId('some-id', undefined, 'r1')).toBe('set-r1-some-id'); + }); + + it('includes both controlsId and storyId when provided', () => { + expect(getControlSetterButtonId('some-id', 'story--name', 'r1')).toBe( + 'set-r1-story--name-some-id' + ); + }); }); diff --git a/code/addons/docs/src/blocks/controls/helpers.ts b/code/addons/docs/src/blocks/controls/helpers.ts index b9895c7eb293..0371921bedd7 100644 --- a/code/addons/docs/src/blocks/controls/helpers.ts +++ b/code/addons/docs/src/blocks/controls/helpers.ts @@ -1,6 +1,7 @@ /** * Adds `control` prefix to make ID attribute more specific. Removes spaces because spaces are not - * allowed in ID attributes + * allowed in ID attributes. The optional `controlsId` disambiguates multiple `` blocks + * rendered for the same story on a single page. * * @example * @@ -10,14 +11,23 @@ * * @link http://xahlee.info/js/html_allowed_chars_in_attribute.html */ -export const getControlId = (value: string, storyId?: string) => { +export const getControlId = (value: string, storyId?: string, controlsId?: string) => { const base = value.replace(/\s+/g, '-'); - return storyId ? `control-${storyId}-${base}` : `control-${base}`; + const parts = ['control']; + if (controlsId) { + parts.push(controlsId); + } + if (storyId) { + parts.push(storyId); + } + parts.push(base); + return parts.join('-'); }; /** * Adds `set` prefix to make ID attribute more specific. Removes spaces because spaces are not - * allowed in ID attributes + * allowed in ID attributes. The optional `controlsId` disambiguates multiple `` blocks + * rendered for the same story on a single page. * * @example * @@ -27,7 +37,15 @@ export const getControlId = (value: string, storyId?: string) => { * * @link http://xahlee.info/js/html_allowed_chars_in_attribute.html */ -export const getControlSetterButtonId = (value: string, storyId?: string) => { +export const getControlSetterButtonId = (value: string, storyId?: string, controlsId?: string) => { const base = value.replace(/\s+/g, '-'); - return storyId ? `set-${storyId}-${base}` : `set-${base}`; + const parts = ['set']; + if (controlsId) { + parts.push(controlsId); + } + if (storyId) { + parts.push(storyId); + } + parts.push(base); + return parts.join('-'); }; diff --git a/code/addons/docs/src/blocks/controls/options/CheckOptions.stories.tsx b/code/addons/docs/src/blocks/controls/options/CheckOptions.stories.tsx index e98ac3cc049e..4c8572e51677 100644 --- a/code/addons/docs/src/blocks/controls/options/CheckOptions.stories.tsx +++ b/code/addons/docs/src/blocks/controls/options/CheckOptions.stories.tsx @@ -10,6 +10,12 @@ const labels = { Cat: 'Catwoman', Rat: 'Ratwoman', }; +// Only `Bat` is labelled; `Cat` and `Rat` should fall back to String(item). +const partialLabels = { + Bat: 'Batwoman', +}; +// Options that collide with Array.prototype method names — the regression case. +const prototypeCollisionOptions = ['reverse', 'map', 'filter']; const objectOptions = { A: { id: 'Aardvark' }, B: { id: 'Bat' }, @@ -70,6 +76,57 @@ export const ArrayInlineLabels: Story = { }, }; +// Partial labels: only 'Bat' is mapped; 'Cat' and 'Rat' fall back to String(item). +export const ArrayLabelsPartial: Story = { + args: { + value: [arrayOptions[0]], + labels: partialLabels, + }, +}; + +export const ArrayInlineLabelsPartial: Story = { + args: { + type: 'inline-check', + value: [arrayOptions[1], arrayOptions[2]], + labels: partialLabels, + }, +}; + +// Regression: when `labels` is emitted as an array by docgen (e.g. Svelte), +// options whose names match Array.prototype methods previously showed +// `function reverse() { [native code] }` instead of the option's string value. +// With the fix, each option must display as String(item) — 'reverse', 'map', 'filter'. +export const ArrayLabelsIsArray: Story = { + name: 'Array Labels (docgen array — prototype-collision fix)', + args: { + value: [prototypeCollisionOptions[0]], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'check' }, + options: prototypeCollisionOptions, + }, + }, +}; + +export const ArrayInlineLabelsIsArray: Story = { + name: 'Array Inline Labels (docgen array — prototype-collision fix)', + args: { + type: 'inline-check', + value: [prototypeCollisionOptions[0], prototypeCollisionOptions[1]], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'inline-check' }, + options: prototypeCollisionOptions, + }, + }, +}; + export const ArrayUndefined: Story = { args: { value: undefined, diff --git a/code/addons/docs/src/blocks/controls/options/Checkbox.tsx b/code/addons/docs/src/blocks/controls/options/Checkbox.tsx index f1d891a2252b..d4857dc1b04f 100644 --- a/code/addons/docs/src/blocks/controls/options/Checkbox.tsx +++ b/code/addons/docs/src/blocks/controls/options/Checkbox.tsx @@ -57,11 +57,13 @@ type CheckboxProps = ControlProps & CheckboxConfig; export const CheckboxControl: FC = ({ name, storyId, + controlsId, options, value, onChange, isInline, argType, + required, }) => { if (!options) { logger.warn(`Checkbox with no options: ${name}`); @@ -89,11 +91,14 @@ export const CheckboxControl: FC = ({ setSelected(selectedKeys(value || [], options)); }, [value]); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); return ( - {name} + + {name} + {required ? ' (required)' : ''} + {Object.keys(options).map((key, index) => { const id = `${controlId}-${index}`; return ( diff --git a/code/addons/docs/src/blocks/controls/options/Options.test.ts b/code/addons/docs/src/blocks/controls/options/Options.test.ts new file mode 100644 index 000000000000..b909fdcc5a6c --- /dev/null +++ b/code/addons/docs/src/blocks/controls/options/Options.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeOptions } from './Options'; + +describe('normalizeOptions', () => { + it('uses String(item) as label when no labels map is provided', () => { + expect(normalizeOptions(['a', 'b', 'c'])).toEqual({ a: 'a', b: 'b', c: 'c' }); + }); + + it('uses labels map when provided with matching keys', () => { + const labels = { a: 'Option A', b: 'Option B' }; + expect(normalizeOptions(['a', 'b'], labels)).toEqual({ + 'Option A': 'a', + 'Option B': 'b', + }); + }); + + it('falls back to String(item) for items missing from labels map', () => { + const labels = { a: 'Option A' }; + expect(normalizeOptions(['a', 'b'], labels)).toEqual({ + 'Option A': 'a', + b: 'b', + }); + }); + + it('falls back to String(item) when a label key exists but its value is undefined', () => { + // Regression guard: { reverse: undefined } previously printed "undefined" as the label + // because Object.hasOwn found the key before we checked the value type. + const labels = { Bat: undefined as any, Cat: 'Catwoman' }; + expect(normalizeOptions(['Bat', 'Cat'], labels)).toEqual({ + Bat: 'Bat', + Catwoman: 'Cat', + }); + }); + + it('does not resolve Array.prototype methods when labels is an array (issue #30142)', () => { + // When Svelte docgen incorrectly passes an array as `labels`, items whose name + // matches an Array prototype method (e.g. 'reverse') must NOT resolve to the + // native function — they should fall back to String(item). + const labelsAsArray: any = ['first', 'second', 'third']; + const options = ['reverse', 'map', 'filter']; + const result = normalizeOptions(options, labelsAsArray); + expect(result).toEqual({ reverse: 'reverse', map: 'map', filter: 'filter' }); + expect(Object.keys(result as object)).not.toContain('function reverse() { [native code] }'); + }); + + it('returns the options object unchanged when options is not an array', () => { + const obj = { 'Option A': 'a', 'Option B': 'b' }; + expect(normalizeOptions(obj as any)).toBe(obj); + }); +}); diff --git a/code/addons/docs/src/blocks/controls/options/Options.tsx b/code/addons/docs/src/blocks/controls/options/Options.tsx index cb0eb2ce79dc..627455f30f57 100644 --- a/code/addons/docs/src/blocks/controls/options/Options.tsx +++ b/code/addons/docs/src/blocks/controls/options/Options.tsx @@ -18,10 +18,19 @@ import { SelectControl } from './Select'; * While non-primitive values are deprecated, they might still not be valid object keys, so the * resulting object is a Label -> Value mapping. */ -const normalizeOptions = (options: Options, labels?: Record) => { +export const normalizeOptions = (options: Options, labels?: Record) => { if (Array.isArray(options)) { return options.reduce((acc, item) => { - acc[labels?.[item] || String(item)] = item; + // Guard against `labels` being an array (e.g. from Svelte docgen), prototype-chain + // lookups, and keys present with an undefined value (partial label maps). + // Checking `typeof === 'string'` covers all three: arrays are skipped by the + // isArray guard, prototype-chain values are functions (not strings), and an + // explicitly-undefined label value is also not a string. + const label = + labels != null && !Array.isArray(labels) && typeof labels[item] === 'string' + ? labels[item] + : String(item); + acc[label] = item; return acc; }, {}); } diff --git a/code/addons/docs/src/blocks/controls/options/Radio.tsx b/code/addons/docs/src/blocks/controls/options/Radio.tsx index c161119747f0..41e3d9e8709a 100644 --- a/code/addons/docs/src/blocks/controls/options/Radio.tsx +++ b/code/addons/docs/src/blocks/controls/options/Radio.tsx @@ -57,24 +57,29 @@ type RadioProps = ControlProps & RadioConfig; export const RadioControl: FC = ({ name, storyId, + controlsId, options, value, onChange, isInline, argType, + required, }) => { if (!options) { logger.warn(`Radio with no options: ${name}`); return <>->; } const selection = selectedKey(value, options); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const readonly = !!argType?.table?.readonly; return ( - {name} + + {name} + {required ? ' (required)' : ''} + {Object.keys(options).map((key, index) => { const id = `${controlId}-${index}`; return ( diff --git a/code/addons/docs/src/blocks/controls/options/RadioOptions.stories.tsx b/code/addons/docs/src/blocks/controls/options/RadioOptions.stories.tsx index e0a5bb706a4e..7b3d48784adc 100644 --- a/code/addons/docs/src/blocks/controls/options/RadioOptions.stories.tsx +++ b/code/addons/docs/src/blocks/controls/options/RadioOptions.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { fn } from 'storybook/test'; +import { expect, fn } from 'storybook/test'; import { OptionsControl } from './Options'; @@ -10,6 +10,18 @@ const labels = { Cat: 'Catwoman', Rat: 'Ratwoman', }; +// Only `Bat` is labelled; `Cat` and `Rat` should fall back to String(item). +const partialLabels = { + Bat: 'Batwoman', +}; +// `Bat` key exists but has an undefined value — must fall back to String(item), not print "undefined". +const undefinedValueLabels: Record = { + Bat: undefined as any, + Cat: 'Catwoman', + Rat: 'Ratwoman', +}; +// Options that collide with Array.prototype method names — the regression case. +const prototypeCollisionOptions = ['reverse', 'map', 'filter']; const objectOptions = { A: { id: 'Aardvark' }, B: { id: 'Bat' }, @@ -60,6 +72,11 @@ export const ArrayLabels: Story = { value: arrayOptions[0], labels, }, + play: async ({ canvas }) => { + await expect(canvas.getByText('Batwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Catwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Ratwoman')).toBeInTheDocument(); + }, }; export const ArrayInlineLabels: Story = { @@ -68,6 +85,100 @@ export const ArrayInlineLabels: Story = { value: arrayOptions[1], labels, }, + play: async ({ canvas }) => { + await expect(canvas.getByText('Batwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Catwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Ratwoman')).toBeInTheDocument(); + }, +}; + +// Partial labels: only 'Bat' is mapped; 'Cat' and 'Rat' fall back to String(item). +export const ArrayLabelsPartial: Story = { + args: { + value: arrayOptions[0], + labels: partialLabels, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText('Batwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Cat')).toBeInTheDocument(); + await expect(canvas.getByText('Rat')).toBeInTheDocument(); + }, +}; + +export const ArrayInlineLabelsPartial: Story = { + args: { + type: 'inline-radio', + value: arrayOptions[1], + labels: partialLabels, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText('Batwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Cat')).toBeInTheDocument(); + await expect(canvas.getByText('Rat')).toBeInTheDocument(); + }, +}; + +// Regression guard: label key exists with value undefined — must NOT render the string "undefined". +export const ArrayLabelsUndefinedValue: Story = { + name: 'Array Labels (undefined label value — must not render "undefined")', + args: { + value: arrayOptions[0], + labels: undefinedValueLabels, + }, + play: async ({ canvas }) => { + // 'Bat' has an undefined label value → falls back to String(item) + await expect(canvas.getByText('Bat')).toBeInTheDocument(); + await expect(canvas.getByText('Catwoman')).toBeInTheDocument(); + await expect(canvas.getByText('Ratwoman')).toBeInTheDocument(); + await expect(canvas.queryByText('undefined')).not.toBeInTheDocument(); + }, +}; + +// Regression: when `labels` is emitted as an array by docgen (e.g. Svelte), +// options whose names match Array.prototype methods previously showed +// `function reverse() { [native code] }` instead of the option's string value. +// With the fix, each option must display as String(item) — 'reverse', 'map', 'filter'. +export const ArrayLabelsIsArray: Story = { + name: 'Array Labels (docgen array — prototype-collision fix)', + args: { + value: prototypeCollisionOptions[0], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'radio' }, + options: prototypeCollisionOptions, + }, + }, + play: async ({ canvas }: Parameters>[0]) => { + await expect(canvas.getByText('reverse')).toBeInTheDocument(); + await expect(canvas.getByText('map')).toBeInTheDocument(); + await expect(canvas.getByText('filter')).toBeInTheDocument(); + await expect(canvas.queryByText(/\[native code\]/)).not.toBeInTheDocument(); + }, +}; + +export const ArrayInlineLabelsIsArray: Story = { + name: 'Array Inline Labels (docgen array — prototype-collision fix)', + args: { + type: 'inline-radio', + value: prototypeCollisionOptions[1], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'inline-radio' }, + options: prototypeCollisionOptions, + }, + }, + play: async ({ canvas }: Parameters>[0]) => { + await expect(canvas.getByText('reverse')).toBeInTheDocument(); + await expect(canvas.getByText('map')).toBeInTheDocument(); + await expect(canvas.getByText('filter')).toBeInTheDocument(); + await expect(canvas.queryByText(/\[native code\]/)).not.toBeInTheDocument(); + }, }; export const ArrayUndefined: Story = { diff --git a/code/addons/docs/src/blocks/controls/options/Select.tsx b/code/addons/docs/src/blocks/controls/options/Select.tsx index 232c0ca12842..612678a0698c 100644 --- a/code/addons/docs/src/blocks/controls/options/Select.tsx +++ b/code/addons/docs/src/blocks/controls/options/Select.tsx @@ -104,12 +104,21 @@ type SelectProps = ControlProps & SelectConfig; const NO_SELECTION = 'Choose option...'; -const SingleSelect: FC = ({ name, storyId, value, options, onChange, argType }) => { +const SingleSelect: FC = ({ + name, + storyId, + controlsId, + value, + options, + onChange, + argType, + required, +}) => { const handleChange = (e: ChangeEvent) => { onChange(options[e.currentTarget.value]); }; const selection = selectedKey(value, options) || NO_SELECTION; - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const readonly = !!argType?.table?.readonly; @@ -119,7 +128,13 @@ const SingleSelect: FC = ({ name, storyId, value, options, onChange {name} - + {NO_SELECTION} @@ -133,7 +148,16 @@ const SingleSelect: FC = ({ name, storyId, value, options, onChange ); }; -const MultiSelect: FC = ({ name, storyId, value, options, onChange, argType }) => { +const MultiSelect: FC = ({ + name, + storyId, + controlsId, + value, + options, + onChange, + argType, + required, +}) => { const handleChange = (e: ChangeEvent) => { const selection = Array.from(e.currentTarget.options) .filter((option) => option.selected) @@ -141,7 +165,7 @@ const MultiSelect: FC = ({ name, storyId, value, options, onChange, onChange(selectedValues(selection, options)); }; const selection = selectedKeys(value, options); - const controlId = getControlId(name, storyId); + const controlId = getControlId(name, storyId, controlsId); const readonly = !!argType?.table?.readonly; @@ -156,6 +180,7 @@ const MultiSelect: FC = ({ name, storyId, value, options, onChange, multiple value={selection} onChange={handleChange} + aria-required={required || undefined} > {Object.keys(options).map((key) => ( diff --git a/code/addons/docs/src/blocks/controls/options/SelectOptions.stories.tsx b/code/addons/docs/src/blocks/controls/options/SelectOptions.stories.tsx index 01fea240177c..1bd3435f026e 100644 --- a/code/addons/docs/src/blocks/controls/options/SelectOptions.stories.tsx +++ b/code/addons/docs/src/blocks/controls/options/SelectOptions.stories.tsx @@ -15,6 +15,15 @@ const labels = { Cat: 'Catwoman', Rat: 'Ratwoman', }; +// Only `Bat` is labelled; `Cat` and `Rat` should fall back to String(item). +const partialLabels = { + Bat: 'Batwoman', +}; +// Option names that collide with Array.prototype method names. +// When `labels` is mistakenly emitted as an array (e.g. by Svelte docgen), +// the old code resolved e.g. labels['reverse'] → Array.prototype.reverse +// (a native function string). The fix must show 'reverse', 'map', 'filter'. +const prototypeCollisionOptions = ['reverse', 'map', 'filter']; const argTypeMultiSelect = { argTypes: { value: { @@ -116,6 +125,58 @@ export const ArrayMultiLabels: Story = { ...argTypeMultiSelect, }; +// Partial labels: only 'Bat' is mapped; 'Cat' and 'Rat' fall back to String(item). +export const ArrayLabelsPartial: Story = { + args: { + value: arrayOptions[0], + labels: partialLabels, + }, +}; + +export const ArrayMultiLabelsPartial: Story = { + args: { + type: 'multi-select', + value: [arrayOptions[1], arrayOptions[2]], + labels: partialLabels, + }, + ...argTypeMultiSelect, +}; + +// Regression: when `labels` is emitted as an array by docgen (e.g. Svelte), +// options whose names match Array.prototype methods previously showed +// `function reverse() { [native code] }` instead of the option's string value. +// With the fix, each option must display as String(item) — 'reverse', 'map', 'filter'. +export const ArrayLabelsIsArray: Story = { + name: 'Array Labels (docgen array — prototype-collision fix)', + args: { + value: prototypeCollisionOptions[0], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'select' }, + options: prototypeCollisionOptions, + }, + }, +}; + +export const ArrayMultiLabelsIsArray: Story = { + name: 'Array Multi Labels (docgen array — prototype-collision fix)', + args: { + type: 'multi-select', + value: [prototypeCollisionOptions[0], prototypeCollisionOptions[1]], + argType: { options: prototypeCollisionOptions }, + labels: ['Reverse', 'Map', 'Filter'] as any, + }, + argTypes: { + value: { + control: { type: 'multi-select' }, + options: prototypeCollisionOptions, + }, + }, +}; + export const Object: Story = { name: 'DEPRECATED: Object', args: { diff --git a/code/addons/docs/src/blocks/controls/types.ts b/code/addons/docs/src/blocks/controls/types.ts index ff909b4bdc8e..348e0cebd722 100644 --- a/code/addons/docs/src/blocks/controls/types.ts +++ b/code/addons/docs/src/blocks/controls/types.ts @@ -3,9 +3,12 @@ import type { ArgType } from '../components/ArgsTable/types'; export interface ControlProps { name: string; storyId?: string; + controlsId?: string; value?: T; defaultValue?: T; argType?: ArgType; + /** Whether the underlying arg is required; surfaced as `aria-required` on the control's input. */ + required?: boolean; onChange: (value?: T) => T | void; onFocus?: (evt: any) => void; onBlur?: (evt: any) => void; diff --git a/code/addons/docs/src/blocks/examples/StoryParameters.stories.tsx b/code/addons/docs/src/blocks/examples/StoryParameters.stories.tsx index 2280656b0b45..5682a2d83871 100644 --- a/code/addons/docs/src/blocks/examples/StoryParameters.stories.tsx +++ b/code/addons/docs/src/blocks/examples/StoryParameters.stories.tsx @@ -24,3 +24,5 @@ export const InlineFalseWithIframeHeight: Story = { export const InlineFalseWithHeight: Story = { parameters: { docs: { story: { inline: false, height: '300px' } } }, }; + +export const HtmlLang: Story = { parameters: { htmlLang: 'fr' } }; diff --git a/code/addons/docs/src/extract-docs-summary.test.ts b/code/addons/docs/src/extract-docs-summary.test.ts new file mode 100644 index 000000000000..fc00fa515c91 --- /dev/null +++ b/code/addons/docs/src/extract-docs-summary.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { extractDocsSummary } from './extract-docs-summary.ts'; + +// Ported from: +// https://github.com/storybookjs/mcp/blob/main/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.test.ts +describe('extractDocsSummary', () => { + describe('import statement removal', () => { + it('should remove single import statements', () => { + const content = `import { Button } from './Button'; + +This is the actual content.`; + expect(extractDocsSummary(content)).toBe('This is the actual content.'); + }); + + it('should remove multiple import statements', () => { + const content = `import { Button } from './Button'; +import { Meta, Story } from '@storybook/blocks'; +import React from 'react'; + +This is the content after imports.`; + expect(extractDocsSummary(content)).toBe('This is the content after imports.'); + }); + + it('should remove side-effect imports', () => { + const content = `import './styles.css'; + +Styled content.`; + expect(extractDocsSummary(content)).toBe('Styled content.'); + }); + }); + + describe('expression removal', () => { + it('should remove simple expressions', () => { + expect(extractDocsSummary('Some text {expression} more text.')).toBe('Some text more text.'); + }); + + it('should remove nested expressions', () => { + expect(extractDocsSummary('Text {outer {inner} value} end.')).toBe('Text end.'); + }); + }); + + describe('JSX/HTML element text extraction', () => { + it('should extract text from nested elements', () => { + const content = `Nested bold text.`; + expect(extractDocsSummary(content)).toBe('Nested bold text.'); + }); + + it('should remove self-closing tags', () => { + const content = `Text before text after end.`; + expect(extractDocsSummary(content)).toBe('Text before text after end.'); + }); + }); + + describe('combined scenarios', () => { + it('should handle typical MDX file content', () => { + const content = `import { Button } from './Button'; +import { Meta, Story } from '@storybook/blocks'; + + + +# Button Component + +The Button component is used for user interactions. + + + Click me + +`; + expect(extractDocsSummary(content)).toBe( + '# Button Component The Button component is used for user interactions. Click me' + ); + }); + + it('should handle empty content', () => { + expect(extractDocsSummary('')).toBeUndefined(); + }); + + it('should handle content with only imports', () => { + const content = `import { Button } from './Button'; +import { Meta } from '@storybook/blocks';`; + expect(extractDocsSummary(content)).toBeUndefined(); + }); + }); + + describe('truncation', () => { + it('should truncate content longer than 90 characters', () => { + const content = + 'This is a very long description that exceeds the maximum summary length limit and should be truncated with an ellipsis.'; + expect(extractDocsSummary(content)).toBe( + 'This is a very long description that exceeds the maximum summary length limit and should b...' + ); + }); + + it('should not truncate content at exactly 90 characters', () => { + const content = 'A'.repeat(90); + expect(extractDocsSummary(content)).toBe(content); + }); + }); + + describe('whitespace handling', () => { + it('should collapse newlines into spaces', () => { + const content = `Line one. +Line two. +Line three.`; + expect(extractDocsSummary(content)).toBe('Line one. Line two. Line three.'); + }); + }); +}); diff --git a/code/addons/docs/src/extract-docs-summary.ts b/code/addons/docs/src/extract-docs-summary.ts new file mode 100644 index 000000000000..d5e63708540e --- /dev/null +++ b/code/addons/docs/src/extract-docs-summary.ts @@ -0,0 +1,41 @@ +/** + * Ported from Storybook MCP's manifest formatter so ref-based docs manifests expose summaries in + * the same shape MCP previously derived itself. + * + * Source: + * https://github.com/storybookjs/mcp/blob/main/packages/mcp/src/utils/manifest-formatter/extract-docs-summary.ts + */ +export const MAX_SUMMARY_LENGTH = 90; + +export function extractDocsSummary(content: string): string | undefined { + let result = content; + + result = result.replace(/^\s*import\s+(?:[\s\S]*?from\s+)?['"][^'"]+['"];?\s*$/gm, ''); + + let prevResult = ''; + while (prevResult !== result) { + prevResult = result; + result = result.replace(/\{[^{}]*\}/g, ''); + } + + result = result.replace(/<[^>]+\/>/g, ''); + + prevResult = ''; + while (prevResult !== result) { + prevResult = result; + result = result.replace(/<(\w+)[^>]*>([\s\S]*?)<\/\1>/g, '$2'); + } + + result = result.replace(/<[^>]+>/g, ''); + result = result.replace(/\s+/g, ' ').trim(); + + if (!result) { + return undefined; + } + + if (result.length > MAX_SUMMARY_LENGTH) { + return `${result.slice(0, MAX_SUMMARY_LENGTH)}...`; + } + + return result; +} diff --git a/code/addons/docs/src/manager.tsx b/code/addons/docs/src/manager.tsx index 49217e0c401a..6b498502a6eb 100644 --- a/code/addons/docs/src/manager.tsx +++ b/code/addons/docs/src/manager.tsx @@ -9,26 +9,41 @@ import { ADDON_ID, PANEL_ID, PARAM_KEY, + type StoryDocsCodePanelParameters, + shouldWaitForServiceSnippet, SNIPPET_RENDERED, -} from '../../../core/src/docs-tools/shared'; +} from 'storybook/internal/docs-tools'; +import type { StoryId } from 'storybook/internal/types'; import type { SourceParameters } from './blocks/blocks'; import { Source } from './blocks/components/Source'; +/** Payload emitted on the `SNIPPET_RENDERED` channel event (see `emitTransformCode`). */ +type SnippetRenderedEvent = { + id?: StoryId; + source?: string; + format?: SyntaxHighlighterFormatTypes; +}; + const CodePanel = ({ active, lastEvent, currentStoryId, + storyParameters, + storyPrepared, }: { active: boolean | undefined; - lastEvent: any | undefined; + lastEvent: SnippetRenderedEvent | undefined; currentStoryId: string | undefined; + storyParameters: StoryDocsCodePanelParameters | undefined; + storyPrepared: boolean | undefined; }) => { + const lastEventMatchesCurrentStory = lastEvent?.id === currentStoryId; const [codeSnippet, setSourceCode] = useState<{ source: string | undefined; format: SyntaxHighlighterFormatTypes | undefined; }>({ - source: lastEvent?.source, - format: lastEvent?.format ?? undefined, + source: lastEventMatchesCurrentStory ? lastEvent?.source : undefined, + format: lastEventMatchesCurrentStory ? (lastEvent?.format ?? undefined) : undefined, }); const parameter = useParameter(PARAM_KEY, { @@ -43,24 +58,35 @@ const CodePanel = ({ }); }, [currentStoryId]); - useChannel({ - [SNIPPET_RENDERED]: ({ source, format }) => { - setSourceCode({ source, format }); + useChannel( + { + [SNIPPET_RENDERED]: ({ id, source, format }) => { + // Ignore snippets emitted for other stories: a slow extraction for the previously selected + // story can resolve after navigation and would otherwise overwrite the current panel. + // `useChannel` captures this handler per `deps`, so it must list `currentStoryId` to compare + // against the currently selected story rather than the one selected on mount. + if (id !== undefined && id !== currentStoryId) { + return; + } + setSourceCode({ source, format }); + }, }, - }); + [currentStoryId] + ); const theme = useTheme(); const isDark = theme.base !== 'light'; + const awaitingServiceSnippet = shouldWaitForServiceSnippet(storyParameters, storyPrepared); + const code = + parameter.source?.code || + codeSnippet.source || + (awaitingServiceSnippet ? '' : parameter.source?.originalSource); + return ( - + ); @@ -92,7 +118,15 @@ addons.register(ADDON_ID, (api) => { const lastEvent = channel?.last(SNIPPET_RENDERED)?.[0]; - return ; + return ( + + ); }, }); }); diff --git a/code/addons/docs/src/manifest.test.ts b/code/addons/docs/src/manifest.test.ts index 41fa36c17403..ca60abbe50f3 100644 --- a/code/addons/docs/src/manifest.test.ts +++ b/code/addons/docs/src/manifest.test.ts @@ -204,6 +204,101 @@ describe('experimental_manifests', () => { ); }); + it('should emit shallow MDX refs when experimentalDocgenServer is enabled', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'example--docs', + name: 'docs', + title: 'Example', + type: 'docs', + importPath: './Example.mdx', + tags: [Tag.MANIFEST, Tag.ATTACHED_MDX], + storiesImports: ['./Example.stories.tsx'], + } satisfies DocsIndexEntry, + { + id: 'standalone--docs', + name: 'docs', + title: 'Standalone', + type: 'docs', + importPath: './Standalone.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests( + { + components: { + v: 0, + components: {}, + meta: { docgen: 'react-component-meta', durationMs: 0 }, + }, + }, + { + manifestEntries, + presets: { + apply: vi.fn().mockResolvedValue({ + experimentalDocgenServer: true, + componentsManifest: true, + }), + }, + } as any + )) as ManifestResult; + + // Shallow refs are structural only; summaries are layered into the static index by core from + // the MDX service snapshots, so the preset never reads files in docgen-server mode. + expect(result.docs?.docs['standalone--docs']).toEqual({ + id: 'standalone--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/standalone--docs.json#/components/standalone--docs/docs/standalone--docs', + }, + }); + expect(result.docs?.v).toBe(1); + expect(result.components?.components.example.docs?.['example--docs']).toEqual({ + id: 'example--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/example.json#/components/example/docs/example--docs', + }, + }); + expect(result.docs?.docs['standalone--docs'].content).toBeUndefined(); + expect(result.components?.components.example.docs?.['example--docs'].content).toBeUndefined(); + }); + + it('builds shallow MDX refs without reading the source file', async () => { + const manifestEntries: IndexEntry[] = [ + { + id: 'missing--docs', + name: 'docs', + title: 'Missing', + type: 'docs', + importPath: './DoesNotExist.mdx', + tags: [Tag.MANIFEST, Tag.UNATTACHED_MDX], + storiesImports: [], + } satisfies DocsIndexEntry, + ]; + + const result = (await manifests(undefined, { + manifestEntries, + presets: { + apply: vi.fn().mockResolvedValue({ + experimentalDocgenServer: true, + componentsManifest: true, + }), + }, + } as any)) as ManifestResult; + + // No `error` despite the missing file: ref mode is purely structural. + expect(result.docs?.docs['missing--docs']).toEqual({ + id: 'missing--docs', + name: 'docs', + mdx: { + $ref: '../services/addon-docs/mdx/missing--docs.json#/components/missing--docs/docs/missing--docs', + }, + }); + }); + it('should handle both attached and unattached docs entries separately', async () => { const existingManifests = { components: { @@ -429,7 +524,7 @@ describe('experimental_manifests', () => { ); }); - it('should not include summary property when analyze returns no summary', async () => { + it('should derive a fallback summary from content when analyze returns no summary', async () => { vol.fromJSON( { './NoSummary.mdx': '# No Summary\n\nThis content has no summary.', @@ -455,6 +550,8 @@ describe('experimental_manifests', () => { expect(result.docs?.docs['nosummary--docs'].content).toBe( '# No Summary\n\nThis content has no summary.' ); - expect(result.docs?.docs['nosummary--docs'].summary).toBeUndefined(); + expect(result.docs?.docs['nosummary--docs'].summary).toBe( + '# No Summary This content has no summary.' + ); }); }); diff --git a/code/addons/docs/src/manifest.ts b/code/addons/docs/src/manifest.ts index d99881f07447..6f31224aae45 100644 --- a/code/addons/docs/src/manifest.ts +++ b/code/addons/docs/src/manifest.ts @@ -1,50 +1,76 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { groupBy } from 'storybook/internal/common'; -import { Tag, analyzeMdx } from 'storybook/internal/core-server'; +import { getComponentIdFromEntry, groupBy } from 'storybook/internal/common'; +import { + type DocsManifestEntry, + type DocsManifestRefEntry, + type MdxDocPayload, + Tag, + analyzeMdx, + mdxManifestRef, +} from 'storybook/internal/core-server'; import { logger } from 'storybook/internal/node-logger'; import type { ComponentManifest, + ComponentsManifest, DocsIndexEntry, IndexEntry, Manifests, - Path, PresetPropertyFn, StorybookConfigRaw, } from 'storybook/internal/types'; -export interface DocsManifestEntry { - id: string; - name: string; - path: Path; - title: string; - content?: string; - summary?: string; - error?: { name: string; message: string }; -} +import { extractDocsSummary } from './extract-docs-summary.ts'; + +export type { DocsManifestEntry } from 'storybook/internal/core-server'; interface DocsManifest { v: number; docs: Record; } -interface ComponentManifestWithDocs extends ComponentManifest { +/** + * A component row carrying docs. In legacy mode this is a full {@link ComponentManifest}; in + * `experimentalDocgenServer` mode core builds the real component rows from docgen, so attached docs + * may be synthesized onto a minimal `{ id, name }` shell — hence the `Partial`. + */ +type ComponentManifestWithDocs = Partial & { + id: string; + name: string; docs?: Record; -} +}; interface ComponentsManifestWithDocs { v: number; components: Record; + meta?: ComponentsManifest['meta']; } -interface ManifestsWithDocs extends Manifests { +/** + * `Manifests` view that also exposes the docs-augmented `docs`/`components` shapes. Uses + * `Omit` so every other manifest keeps its `Manifests` typing while + * `components` is specialized to carry docs. + */ +interface ManifestsWithDocs extends Omit { docs?: DocsManifest; components?: ComponentsManifestWithDocs; } -/** Converts a DocsIndexEntry to a DocsManifestEntry by reading its file content. */ -export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise { +/** Builds the manifest entry for one docs index entry, owning the single component-id context. */ +type DocBuilder = ( + entry: DocsIndexEntry, + componentId: string +) => Promise | DocsManifestEntry; + +/** + * Reads one MDX file into a full payload (content + derived summary). + * + * `summary` is derived once here — an explicit `Meta summary` when present, falling back to text + * extracted from the content — so the service, the legacy inline manifest, and the ref index all + * share one summary. It is omitted (like `content`) when the file cannot be read. + */ +export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise { const absolutePath = path.join(process.cwd(), entry.importPath); try { const content = await fs.readFile(absolutePath, 'utf-8'); @@ -55,6 +81,7 @@ export async function createDocsManifestEntry(entry: DocsIndexEntry): Promise> { - if (entries.length === 0) { - return {}; - } - const entriesWithContent = await Promise.all(entries.map(createDocsManifestEntry)); - return Object.fromEntries(entriesWithContent.map((entry) => [entry.id, entry])); + const docs = await Promise.all( + entries.map(async (entry) => [entry.id, await build(entry, entry.id)] as const) + ); + + return Object.fromEntries(docs); } /** - * Extracts attached docs entries by adding them to their corresponding component manifests. - * - * Returns the updated components manifest with docs added to each component. + * Adds attached docs to their owning component, immutably synthesizing a minimal component row when + * one does not exist yet (the docgen-server components manifest starts empty). */ -export async function extractAttachedDocsEntries( +async function applyAttachedDocs( entries: DocsIndexEntry[], - existingComponents: ComponentsManifestWithDocs | undefined + existingComponents: ComponentsManifestWithDocs | undefined, + build: DocBuilder ): Promise
args
argTypes
Nested bold text.