From d8de2e2d574b7edaaaaa94d03481b61f8e01c651 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 15 Sep 2026 23:12:55 +0300 Subject: [PATCH 1/5] feat(react-storybook-addon-playground): add published no-op package scaffold Ship 0.0.1 on latest first so later experimental playground releases do not take the latest npm tag. Co-authored-by: Cursor --- .github/CODEOWNERS | 1 + ...-99650265-91f2-458b-bfdb-cf5dda68c498.json | 6 ++ .../.babelrc.json | 3 + .../react-storybook-addon-playground/.swcrc | 34 +++++++++++ .../react-storybook-addon-playground/LICENSE | 15 +++++ .../README.md | 22 +++++++ .../config/api-extractor.json | 4 ++ .../config/tests.js | 1 + .../eslint.config.js | 13 +++++ .../react-storybook-addon-playground.api.md | 18 ++++++ .../jest.config.js | 28 +++++++++ .../package.json | 58 +++++++++++++++++++ .../preset.js | 5 ++ .../project.json | 21 +++++++ .../src/index.ts | 1 + .../src/preset/preview.ts | 1 + .../src/public-types.ts | 17 ++++++ .../src/styles.css | 1 + .../tsconfig.json | 23 ++++++++ .../tsconfig.lib.json | 15 +++++ .../tsconfig.spec.json | 9 +++ tsconfig.base.all.json | 9 ++- tsconfig.base.json | 3 + yarn.lock | 16 +++++ 24 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json create mode 100644 packages/react-components/react-storybook-addon-playground/.babelrc.json create mode 100644 packages/react-components/react-storybook-addon-playground/.swcrc create mode 100644 packages/react-components/react-storybook-addon-playground/LICENSE create mode 100644 packages/react-components/react-storybook-addon-playground/README.md create mode 100644 packages/react-components/react-storybook-addon-playground/config/api-extractor.json create mode 100644 packages/react-components/react-storybook-addon-playground/config/tests.js create mode 100644 packages/react-components/react-storybook-addon-playground/eslint.config.js create mode 100644 packages/react-components/react-storybook-addon-playground/etc/react-storybook-addon-playground.api.md create mode 100644 packages/react-components/react-storybook-addon-playground/jest.config.js create mode 100644 packages/react-components/react-storybook-addon-playground/package.json create mode 100644 packages/react-components/react-storybook-addon-playground/preset.js create mode 100644 packages/react-components/react-storybook-addon-playground/project.json create mode 100644 packages/react-components/react-storybook-addon-playground/src/index.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/preset/preview.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/public-types.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/styles.css create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.json create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.lib.json create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.spec.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 90627caa29ee9..94bdae50b6ef3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -288,6 +288,7 @@ packages/react-components/react-breadcrumb/stories @microsoft/cxe-prg packages/react-components/react-drawer/library @microsoft/cxe-prg @mainframev packages/react-components/react-drawer/stories @microsoft/cxe-prg @mainframev packages/react-components/react-storybook-addon-export-to-sandbox @microsoft/fluentui-react-build +packages/react-components/react-storybook-addon-playground @microsoft/fluentui-react-build packages/react-components/babel-preset-storybook-full-source @microsoft/fluentui-react-build packages/react-components/react-jsx-runtime @microsoft/teams-prg packages/react-components/react-toast/library @microsoft/teams-prg diff --git a/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json new file mode 100644 index 0000000000000..a3ff4ddc7a388 --- /dev/null +++ b/change/@fluentui-react-storybook-addon-playground-99650265-91f2-458b-bfdb-cf5dda68c498.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "feat: add @fluentui/react-storybook-addon-playground package scaffold", + "packageName": "@fluentui/react-storybook-addon-playground", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/react-components/react-storybook-addon-playground/.babelrc.json b/packages/react-components/react-storybook-addon-playground/.babelrc.json new file mode 100644 index 0000000000000..38bf1f7a945cf --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/.babelrc.json @@ -0,0 +1,3 @@ +{ + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/react-components/react-storybook-addon-playground/.swcrc b/packages/react-components/react-storybook-addon-playground/.swcrc new file mode 100644 index 0000000000000..7f9fbdf94785d --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/.swcrc @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "baseUrl": ".", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + }, + "optimizer": { + "simplify": true + } + }, + "target": "es2019" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/react-components/react-storybook-addon-playground/LICENSE b/packages/react-components/react-storybook-addon-playground/LICENSE new file mode 100644 index 0000000000000..3e7475939fea6 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-storybook-addon-playground + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-storybook-addon-playground/README.md b/packages/react-components/react-storybook-addon-playground/README.md new file mode 100644 index 0000000000000..464254a9fae6b --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/README.md @@ -0,0 +1,22 @@ +# @fluentui/react-storybook-addon-playground + +**React Storybook Addon Playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** + +This package is the published scaffold for an in-browser TSX playground Storybook addon. The playground implementation will land in an experimental release so that `latest` stays on this no-op 0.0.x line. + +## Installation + +```sh +yarn add @fluentui/react-storybook-addon-playground +``` + +## Usage + +This initial release is a no-op Storybook addon. Registering it has no effect on Docs or Canvas until the experimental playground ships. + +```js +// .storybook/main.js +module.exports = { + addons: ['@fluentui/react-storybook-addon-playground'], +}; +``` diff --git a/packages/react-components/react-storybook-addon-playground/config/api-extractor.json b/packages/react-components/react-storybook-addon-playground/config/api-extractor.json new file mode 100644 index 0000000000000..e533bf30b48a2 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json" +} diff --git a/packages/react-components/react-storybook-addon-playground/config/tests.js b/packages/react-components/react-storybook-addon-playground/config/tests.js new file mode 100644 index 0000000000000..2e211ae9e2142 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/config/tests.js @@ -0,0 +1 @@ +/** Jest test setup file. */ diff --git a/packages/react-components/react-storybook-addon-playground/eslint.config.js b/packages/react-components/react-storybook-addon-playground/eslint.config.js new file mode 100644 index 0000000000000..821668e38a649 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/eslint.config.js @@ -0,0 +1,13 @@ +// @ts-check + +const { defineConfig } = require('eslint/config'); +const fluentPlugin = require('@fluentui/eslint-plugin'); + +module.exports = defineConfig([ + ...fluentPlugin.configs['flat/node'], + { + rules: { + '@fluentui/react-components/enforce-use-client': 'off', + }, + }, +]); diff --git a/packages/react-components/react-storybook-addon-playground/etc/react-storybook-addon-playground.api.md b/packages/react-components/react-storybook-addon-playground/etc/react-storybook-addon-playground.api.md new file mode 100644 index 0000000000000..44ef055594f2b --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/etc/react-storybook-addon-playground.api.md @@ -0,0 +1,18 @@ +## API Report File for "@fluentui/react-storybook-addon-playground" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +interface Parameters_2 { +} +export { Parameters_2 as Parameters } + +// @public +export interface PresetConfig { +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-storybook-addon-playground/jest.config.js b/packages/react-components/react-storybook-addon-playground/jest.config.js new file mode 100644 index 0000000000000..995a6f0824f45 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/jest.config.js @@ -0,0 +1,28 @@ +// @ts-check +/* eslint-disable */ + +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse(readFileSync(join(__dirname, '.swcrc'), 'utf-8')); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'react-storybook-addon-playground', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', swcJestConfig], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], +}; diff --git a/packages/react-components/react-storybook-addon-playground/package.json b/packages/react-components/react-storybook-addon-playground/package.json new file mode 100644 index 0000000000000..b69440028d0df --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/package.json @@ -0,0 +1,58 @@ +{ + "name": "@fluentui/react-storybook-addon-playground", + "version": "0.0.0", + "description": "Storybook addon that ships an in-browser TSX playground for Fluent UI React v9 stories", + "main": "lib-commonjs/index.js", + "module": "lib/index.js", + "typings": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + }, + "peerDependencies": { + "@storybook/react": ">=9.1.17", + "@types/react": ">=16.14.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "node": { + "module": "./lib/index.js", + "default": "./lib-commonjs/index.js" + }, + "import": "./lib/index.js", + "require": "./lib-commonjs/index.js" + }, + "./preset": "./preset.js", + "./styles.css": "./dist/styles.css", + "./package.json": "./package.json" + }, + "sideEffects": [ + "./dist/styles.css" + ], + "files": [ + "*.md", + "dist/*.d.ts", + "dist/styles.css", + "lib", + "lib-commonjs", + "preset.js" + ] +} diff --git a/packages/react-components/react-storybook-addon-playground/preset.js b/packages/react-components/react-storybook-addon-playground/preset.js new file mode 100644 index 0000000000000..f6448dfa4a01f --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/preset.js @@ -0,0 +1,5 @@ +function previewAnnotations(entry = []) { + return [...entry, require.resolve('./lib/preset/preview')]; +} + +module.exports = { previewAnnotations }; diff --git a/packages/react-components/react-storybook-addon-playground/project.json b/packages/react-components/react-storybook-addon-playground/project.json new file mode 100644 index 0000000000000..9a02c8984d45a --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/project.json @@ -0,0 +1,21 @@ +{ + "name": "react-storybook-addon-playground", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "implicitDependencies": [], + "sourceRoot": "packages/react-components/react-storybook-addon-playground/src", + "tags": ["vNext", "platform:any", "tools"], + "targets": { + "build": { + "options": { + "assets": [ + { + "input": "{projectRoot}/src", + "output": "dist", + "glob": "styles.css" + } + ] + } + } + } +} diff --git a/packages/react-components/react-storybook-addon-playground/src/index.ts b/packages/react-components/react-storybook-addon-playground/src/index.ts new file mode 100644 index 0000000000000..f292cb543f77e --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/index.ts @@ -0,0 +1 @@ +export type { ParametersExtension as Parameters, PresetConfig } from './public-types'; diff --git a/packages/react-components/react-storybook-addon-playground/src/preset/preview.ts b/packages/react-components/react-storybook-addon-playground/src/preset/preview.ts new file mode 100644 index 0000000000000..cb0ff5c3b541f --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/preset/preview.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/react-components/react-storybook-addon-playground/src/public-types.ts b/packages/react-components/react-storybook-addon-playground/src/public-types.ts new file mode 100644 index 0000000000000..bcb926783210f --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/public-types.ts @@ -0,0 +1,17 @@ +/** + * NOTE: + * Don't import anything from source code in this file !! + * + * only pure API definitions of addon are allowed to live here, that are used both internal and for external storybook `Parameter` type extensions + */ + +/** + * Options passed to the addon via Storybook's `addons` entry + * (`loadWorkspaceAddon(..., { options })` or `{ name, options }`). + * + * This initial release is a package scaffold. The playground configuration surface + * will be added in a follow-up experimental release. + */ +export interface PresetConfig {} + +export interface ParametersExtension {} diff --git a/packages/react-components/react-storybook-addon-playground/src/styles.css b/packages/react-components/react-storybook-addon-playground/src/styles.css new file mode 100644 index 0000000000000..cb1663e454bd1 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/styles.css @@ -0,0 +1 @@ +/* Playground docs-launcher styles will be added in a follow-up experimental release. */ diff --git a/packages/react-components/react-storybook-addon-playground/tsconfig.json b/packages/react-components/react-storybook-addon-playground/tsconfig.json new file mode 100644 index 0000000000000..3dee174f208dd --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true, + "esModuleInterop": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/react-components/react-storybook-addon-playground/tsconfig.lib.json b/packages/react-components/react-storybook-addon-playground/tsconfig.lib.json new file mode 100644 index 0000000000000..600da72d9b466 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "DOM"], + "declaration": true, + "declarationDir": "../../../dist/out-tsc/types", + "outDir": "../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment", "node"], + "module": "CommonJS" + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/react-components/react-storybook-addon-playground/tsconfig.spec.json b/packages/react-components/react-storybook-addon-playground/tsconfig.spec.json new file mode 100644 index 0000000000000..469fcba4d7ba7 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] +} diff --git a/tsconfig.base.all.json b/tsconfig.base.all.json index 8df0768990e6d..c38e962140302 100644 --- a/tsconfig.base.all.json +++ b/tsconfig.base.all.json @@ -126,12 +126,12 @@ "@fluentui/react-field": ["packages/react-components/react-field/library/src/index.ts"], "@fluentui/react-field-stories": ["packages/react-components/react-field/stories/src/index.ts"], "@fluentui/react-focus-management": ["packages/react-focus-management/src/index.ts"], - "@fluentui/react-headless-components-preview/*": [ - "packages/react-components/react-headless-components-preview/library/src/*.ts" - ], "@fluentui/react-headless-components-preview-stories": [ "packages/react-components/react-headless-components-preview/stories/src/index.ts" ], + "@fluentui/react-headless-components-preview/*": [ + "packages/react-components/react-headless-components-preview/library/src/*.ts" + ], "@fluentui/react-icons-compat": ["packages/react-components/react-icons-compat/library/src/index.ts"], "@fluentui/react-icons-compat-stories": ["packages/react-components/react-icons-compat/stories/src/index.ts"], "@fluentui/react-image": ["packages/react-components/react-image/library/src/index.ts"], @@ -217,6 +217,9 @@ "@fluentui/react-storybook-addon-export-to-sandbox": [ "packages/react-components/react-storybook-addon-export-to-sandbox/src/index.ts" ], + "@fluentui/react-storybook-addon-playground": [ + "packages/react-components/react-storybook-addon-playground/src/index.ts" + ], "@fluentui/react-swatch-picker": ["packages/react-components/react-swatch-picker/library/src/index.ts"], "@fluentui/react-swatch-picker-stories": ["packages/react-components/react-swatch-picker/stories/src/index.ts"], "@fluentui/react-switch": ["packages/react-components/react-switch/library/src/index.ts"], diff --git a/tsconfig.base.json b/tsconfig.base.json index a96e4cd5c14ff..3928c46c8d259 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -173,6 +173,9 @@ "@fluentui/react-storybook-addon-export-to-sandbox": [ "packages/react-components/react-storybook-addon-export-to-sandbox/src/index.ts" ], + "@fluentui/react-storybook-addon-playground": [ + "packages/react-components/react-storybook-addon-playground/src/index.ts" + ], "@fluentui/react-swatch-picker": ["packages/react-components/react-swatch-picker/library/src/index.ts"], "@fluentui/react-swatch-picker-stories": ["packages/react-components/react-swatch-picker/stories/src/index.ts"], "@fluentui/react-switch": ["packages/react-components/react-switch/library/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index 4fa370782d88e..dabaa3f5eb439 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5575,6 +5575,22 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/react-storybook-addon-playground@workspace:packages/react-components/react-storybook-addon-playground": + version: 0.0.0-use.local + resolution: "@fluentui/react-storybook-addon-playground@workspace:packages/react-components/react-storybook-addon-playground" + dependencies: + "@swc/helpers": "npm:^0.5.1" + peerDependencies: + "@storybook/react": ">=9.1.17" + "@types/react": ">=16.14.0 <20.0.0" + react: ">=16.14.0 <20.0.0" + react-dom: ">=16.14.0 <20.0.0" + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@fluentui/react-storybook-addon@npm:*, @fluentui/react-storybook-addon@workspace:packages/react-components/react-storybook-addon": version: 0.0.0-use.local resolution: "@fluentui/react-storybook-addon@workspace:packages/react-components/react-storybook-addon" From 7150904e46b04fd73fd2ffd7b67373c1e95952a0 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 15 Sep 2026 23:16:56 +0300 Subject: [PATCH 2/5] feat(react-storybook-addon-playground): add in-browser TSX playground Implements the configurable Monaco playground with a sandboxed preview, Docs launcher, and Storybook wiring on top of the published package scaffold. Co-authored-by: Cursor --- apps/public-docsite-v9-headless/package.json | 1 + apps/public-docsite-v9-headless/project.json | 14 +- apps/public-docsite-v9/.storybook/main.js | 1 + ...-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json | 6 + .../stories/.storybook/css.d.ts | 1 + .../stories/.storybook/main.js | 47 +- .../stories/.storybook/playground.setup.tsx | 52 ++ .../stories/.storybook/preview.js | 1 + .../stories/.storybook/tsconfig.json | 2 +- .../stories/project.json | 20 +- .../src/sandbox-utils.ts | 3 +- .../react-storybook-addon-playground/.swcrc | 3 +- .../README.md | 96 ++- .../eslint.config.js | 11 +- .../react-storybook-addon-playground.api.md | 99 +++ .../package.json | 25 +- .../preset.js | 35 +- .../project.json | 11 + .../withOpenInPlaygroundButton.spec.ts | 183 +++++ .../decorators/withOpenInPlaygroundButton.ts | 85 +++ .../src/defaultSetup.tsx | 73 ++ .../src/index.ts | 8 +- .../src/playground/Editor.styles.ts | 10 + .../src/playground/Editor.tsx | 182 +++++ .../src/playground/Playground.styles.ts | 303 ++++++++ .../src/playground/Playground.tsx | 660 ++++++++++++++++++ .../src/playground/Preview.styles.ts | 27 + .../src/playground/Preview.tsx | 110 +++ .../src/playground/compiler.ts | 87 +++ .../src/playground/cssModules.spec.ts | 78 +++ .../src/playground/cssModules.ts | 86 +++ .../src/playground/editorTheme.ts | 129 ++++ .../src/playground/formatter.spec.ts | 34 + .../src/playground/formatter.ts | 78 +++ .../src/playground/main.tsx | 42 ++ .../src/playground/monaco.ts | 48 ++ .../src/playground/playground.html | 26 + .../src/playground/runner.spec.ts | 49 ++ .../src/playground/runner.ts | 51 ++ .../src/playground/runtime.ts | 59 ++ .../src/playground/sandbox.ts | 189 +++++ .../src/playground/shortcuts.spec.ts | 23 + .../src/playground/shortcuts.ts | 23 + .../src/playground/themes.ts | 21 + .../src/playground/typings.ts | 34 + .../src/playground/useMediaQuery.ts | 24 + .../src/playground/useSplitPane.spec.ts | 70 ++ .../src/playground/useSplitPane.ts | 110 +++ .../src/preset/preset.ts | 1 + .../src/preset/preview.ts | 6 +- .../src/public-types.ts | 37 +- .../src/setup.ts | 39 ++ .../src/styles.css | 30 +- .../src/types.ts | 17 + .../src/url.spec.ts | 91 +++ .../src/url.ts | 117 ++++ .../src/webpack.spec.ts | 65 ++ .../src/webpack.ts | 304 ++++++++ .../tools/collect-typings.js | 461 ++++++++++++ .../tools/collect-typings.spec.js | 237 +++++++ .../tsconfig.json | 3 + .../tsconfig.lib.json | 2 +- .../tsconfig.playground.json | 11 + .../webpack.playground.config.js | 123 ++++ yarn.lock | 12 +- 65 files changed, 4856 insertions(+), 30 deletions(-) create mode 100644 change/@fluentui-react-storybook-addon-export-to-sandbox-bfb6dbfb-a9e5-47e0-b04d-be8d1f1d4399.json create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/css.d.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/decorators/withOpenInPlaygroundButton.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/decorators/withOpenInPlaygroundButton.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/defaultSetup.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Editor.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Editor.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Playground.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Playground.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Preview.styles.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/Preview.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/compiler.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/cssModules.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/cssModules.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/editorTheme.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/formatter.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/formatter.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/main.tsx create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/monaco.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/playground.html create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runner.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runner.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/runtime.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/sandbox.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/shortcuts.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/shortcuts.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/themes.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/typings.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useMediaQuery.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useSplitPane.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/playground/useSplitPane.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/preset/preset.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/setup.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/types.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/url.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/url.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts create mode 100644 packages/react-components/react-storybook-addon-playground/src/webpack.ts create mode 100644 packages/react-components/react-storybook-addon-playground/tools/collect-typings.js create mode 100644 packages/react-components/react-storybook-addon-playground/tools/collect-typings.spec.js create mode 100644 packages/react-components/react-storybook-addon-playground/tsconfig.playground.json create mode 100644 packages/react-components/react-storybook-addon-playground/webpack.playground.config.js diff --git a/apps/public-docsite-v9-headless/package.json b/apps/public-docsite-v9-headless/package.json index 8e5736f87ea10..524d1e28e385b 100644 --- a/apps/public-docsite-v9-headless/package.json +++ b/apps/public-docsite-v9-headless/package.json @@ -16,6 +16,7 @@ "@fluentui/react-headless-components-preview": "*", "@fluentui/react-storybook-addon": "*", "@fluentui/react-storybook-addon-export-to-sandbox": "*", + "@fluentui/react-storybook-addon-playground": "*", "react": "19.2.0", "react-dom": "19.2.0", "tslib": "^2.1.0" diff --git a/apps/public-docsite-v9-headless/project.json b/apps/public-docsite-v9-headless/project.json index d48c59b15dd70..883d855c1afa2 100644 --- a/apps/public-docsite-v9-headless/project.json +++ b/apps/public-docsite-v9-headless/project.json @@ -8,7 +8,12 @@ "build-storybook": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ] @@ -16,7 +21,12 @@ "build-storybook:docsite": { "dependsOn": [ { - "projects": ["react-storybook-addon", "react-storybook-addon-export-to-sandbox", "storybook-llms-extractor"], + "projects": [ + "react-storybook-addon", + "react-storybook-addon-export-to-sandbox", + "react-storybook-addon-playground", + "storybook-llms-extractor" + ], "target": "build" } ], diff --git a/apps/public-docsite-v9/.storybook/main.js b/apps/public-docsite-v9/.storybook/main.js index b609d7679479f..ef484c2568f6e 100644 --- a/apps/public-docsite-v9/.storybook/main.js +++ b/apps/public-docsite-v9/.storybook/main.js @@ -31,6 +31,7 @@ module.exports = /** @type {Omit} + */ +function getHeadlessPlaygroundModules() { + /** @type {Record} */ + const modules = { + '@fluentui/react-icons': '@fluentui/react-icons', + }; + + for (const exportPath of Object.keys(headlessPackageJson.exports ?? {})) { + if (exportPath === '.' || exportPath === './package.json') { + continue; + } + + const specifier = `${HEADLESS_PACKAGE_NAME}${exportPath.slice(1)}`; + modules[specifier] = specifier; + } + + return modules; } module.exports = /** @type {Omit} */ ({ ...rootMain, stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], addons: [ - ...rootMain.addons.filter(isNotExportToSandboxAddon), + ...rootMain.addons.filter(isNotRootSandboxOrPlaygroundAddon), loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { tsConfigPath, /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ @@ -37,6 +69,15 @@ module.exports = /** @type {Omit { const localConfig = /** @type {any} */ ({ ...rootMain.webpackFinal(config, options) }); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx new file mode 100644 index 0000000000000..ec08ef5656a26 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/playground.setup.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +import './tokens.css'; + +polyfillBodyAndObserve(); + +type HeadlessPlaygroundTheme = 'light' | 'dark'; + +const defaultCode = `import * as React from 'react'; +import { Button } from '@fluentui/react-headless-components-preview/button'; + +export default () => { + const [count, setCount] = React.useState(0); + + return ( + + ); +}; +`; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + subtitle: 'Headless', + defaultCode, + themes: [ + { id: 'light', label: 'Light', value: 'light', dark: false }, + { id: 'dark', label: 'Dark', value: 'dark', dark: true }, + ], + render: ({ Component, theme }) => ( + +
+ +
+
+ ), +}); diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js index a16824d5be686..1ad92661b8e71 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/preview.js @@ -1,4 +1,5 @@ import { polyfillBodyAndObserve } from '@microsoft/focusgroup-polyfill/shadowless'; +import '../../../react-storybook-addon-playground/src/styles.css'; import * as rootPreview from '../../../../../.storybook/preview'; diff --git a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json index 94fa013fc1872..4911d72ee49f5 100644 --- a/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json +++ b/packages/react-components/react-headless-components-preview/stories/.storybook/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "bundler", "types": ["static-assets", "environment"] }, - "include": ["*.js"] + "include": ["*.js", "*.tsx", "*.d.ts"] } diff --git a/packages/react-components/react-headless-components-preview/stories/project.json b/packages/react-components/react-headless-components-preview/stories/project.json index 0effce2bfe286..53fa82ec3cd62 100644 --- a/packages/react-components/react-headless-components-preview/stories/project.json +++ b/packages/react-components/react-headless-components-preview/stories/project.json @@ -4,5 +4,23 @@ "projectType": "library", "sourceRoot": "packages/react-components/react-headless-components-preview/stories/src", "tags": ["vNext", "platform:web", "type:stories", "react-headless"], - "implicitDependencies": [] + "implicitDependencies": [], + "targets": { + "storybook": { + "dependsOn": [ + { + "projects": ["react-storybook-addon-playground"], + "target": "build" + } + ] + }, + "build-storybook": { + "dependsOn": [ + { + "projects": ["react-storybook-addon-playground"], + "target": "build" + } + ] + } + } } diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts index 1c118531da63d..84066e72ba1ad 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/sandbox-utils.ts @@ -26,8 +26,9 @@ export function prepareSandboxContainers(context: StoryContext) { } return Array.from(rootElements).map(rootElement => { + // Exclude buttons added by this addon and by @fluentui/react-storybook-addon-playground const showCodeButton = rootElement.querySelector( - '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button)', + '.docblock-code-toggle:not(.with-code-sandbox-button):not(.with-open-in-new-tab-button):not(.with-open-in-playground-button)', ); const container = showCodeButton?.parentElement; diff --git a/packages/react-components/react-storybook-addon-playground/.swcrc b/packages/react-components/react-storybook-addon-playground/.swcrc index 7f9fbdf94785d..30f79f5975e21 100644 --- a/packages/react-components/react-storybook-addon-playground/.swcrc +++ b/packages/react-components/react-storybook-addon-playground/.swcrc @@ -7,7 +7,8 @@ "/**/*.spec.ts", "/**/*.spec.tsx", "/**/*.test.ts", - "/**/*.test.tsx" + "/**/*.test.tsx", + "/**/src/playground/**" ], "jsc": { "baseUrl": ".", diff --git a/packages/react-components/react-storybook-addon-playground/README.md b/packages/react-components/react-storybook-addon-playground/README.md index 464254a9fae6b..d98c42c279c15 100644 --- a/packages/react-components/react-storybook-addon-playground/README.md +++ b/packages/react-components/react-storybook-addon-playground/README.md @@ -1,8 +1,18 @@ # @fluentui/react-storybook-addon-playground -**React Storybook Addon Playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** +**Configurable Storybook TSX playground for [Fluent UI React](https://developer.microsoft.com/en-us/fluentui)** -This package is the published scaffold for an in-browser TSX playground Storybook addon. The playground implementation will land in an experimental release so that `latest` stays on this no-op 0.0.x line. +This addon serves a prebuilt Monaco playground shell and adds an **Open in Playground** button next to "Show code" in +Storybook Docs mode. The consumer's Storybook Webpack build produces a separate runtime for React, configured packages, +an optional setup module, and Monaco declarations. User code runs inside a sandboxed preview iframe. + +## Features + +- Consumer-controlled import map (`options.modules`) eagerly compiled into the sandbox runtime by Storybook Webpack +- Private registry and workspace packages without a runtime CDN +- Build-time declaration collection for Monaco IntelliSense (from `modules` + optional `typings`) +- Optional TSX `setup` module for branding, themes and provider/render behavior (Fluent defaults when omitted) +- Sandboxed preview (`sandbox="allow-scripts"`), Prettier formatting, error reporting and shareable URL state ## Installation @@ -12,11 +22,87 @@ yarn add @fluentui/react-storybook-addon-playground ## Usage -This initial release is a no-op Storybook addon. Registering it has no effect on Docs or Canvas until the experimental playground ships. - ```js // .storybook/main.js +const path = require('path'); + module.exports = { - addons: ['@fluentui/react-storybook-addon-playground'], + addons: [ + { + name: '@fluentui/react-storybook-addon-playground', + options: { + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-components/unstable': '@fluentui/react-components/unstable', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: path.resolve(__dirname, './playground.setup.tsx'), // optional + // typings: ['@fluentui/react-theme'], // optional declaration-only entries + }, + }, + ], }; ``` + +```js +// .storybook/preview.js +import '@fluentui/react-storybook-addon-playground/styles.css'; +``` + +`modules` maps public imports accepted by the editor to package requests resolved by the consumer's Webpack +configuration. React runtime entries are provided automatically. `typings` adds declaration-only entries. + +The optional setup module default-exports a value created with `definePlaygroundSetup`: + +```tsx +import * as React from 'react'; +import { FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { definePlaygroundSetup } from '@fluentui/react-storybook-addon-playground/setup'; + +export default definePlaygroundSetup({ + title: 'Fluent UI Playground', + defaultCode: `import { Button } from '@fluentui/react-components'; + +export default () => ;`, + themes: [{ id: 'web-light', label: 'Web Light', value: webLightTheme }], + render: ({ Component, theme }) => ( + + + + ), +}); +``` + +When `setup` is omitted, the addon's built-in Fluent UI default setup is used. + +### Story source + +The button reads the story source from `parameters.fullSource`, which is injected at build time by +`@fluentui/babel-preset-storybook-full-source` (registered by `@fluentui/react-storybook-addon-export-to-sandbox`). +Other consumers can populate the same parameter with their own Storybook source transform. The button is not rendered +when `parameters.fullSource` is unavailable. + +### Disabling per story + +```ts +export const MyStory = () => ; +`; + +describe('url', () => { + describe('encodeCode / decodeCode', () => { + it('round trips source code', () => { + expect(decodeCode(encodeCode(sampleCode))).toBe(sampleCode); + }); + + it('produces a URL safe payload', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toMatch(/^[A-Za-z0-9+\-$]+$/); + }); + + it('tolerates "+" being decoded as a space by URLSearchParams', () => { + const encoded = encodeCode(sampleCode); + + expect(encoded).toContain('+'); + expect(decodeCode(encoded.replace(/\+/g, ' '))).toBe(sampleCode); + }); + + it('returns null for empty or invalid payloads', () => { + expect(decodeCode('')).toBeNull(); + expect(decodeCode('not-a-valid-payload!!!')).toBeNull(); + }); + }); + + describe('createCodeHash / decodeCodeFromHash', () => { + it('creates a hash with the code param', () => { + const hash = createCodeHash(sampleCode); + + expect(hash.startsWith(`#${CODE_HASH_PARAM}=`)).toBe(true); + expect(decodeCodeFromHash(hash)).toBe(sampleCode); + }); + + it('accepts hash with and without the leading "#"', () => { + const hash = createCodeHash(sampleCode); + + expect(decodeCodeFromHash(hash.slice(1))).toBe(sampleCode); + }); + + it('ignores unrelated hash params', () => { + expect(decodeCodeFromHash('')).toBeNull(); + expect(decodeCodeFromHash('#foo=bar')).toBeNull(); + expect(decodeCodeFromHash(`#foo=bar&${createCodeHash(sampleCode).slice(1)}`)).toBe(sampleCode); + }); + }); + + describe('createPlaygroundUrl', () => { + it('defaults to the playground shell path', () => { + const url = createPlaygroundUrl(sampleCode); + + expect(url.startsWith(`./${PLAYGROUND_PATH}#${CODE_HASH_PARAM}=`)).toBe(true); + }); + + it('supports a custom base url', () => { + const url = createPlaygroundUrl(sampleCode, 'https://example.com/storybook/playground.html'); + const [base, hash] = url.split('#'); + + expect(base).toBe('https://example.com/storybook/playground.html'); + expect(decodeCodeFromHash(`#${hash}`)).toBe(sampleCode); + }); + + it('encodes CSS modules next to the source so the sandbox can apply them', () => { + const cssModules = [{ name: 'button.module.css', source: '.root { color: red; }' }]; + const url = createPlaygroundUrl(sampleCode, undefined, cssModules); + const hash = `#${url.split('#')[1]}`; + + expect(hash).toContain(`${CSS_HASH_PARAM}=`); + expect(decodePlaygroundStateFromHash(hash)).toEqual({ code: sampleCode, cssModules }); + expect(createPlaygroundHash({ code: sampleCode, cssModules }).startsWith('#')).toBe(true); + }); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/url.ts b/packages/react-components/react-storybook-addon-playground/src/url.ts new file mode 100644 index 0000000000000..d5e6f865d938e --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/url.ts @@ -0,0 +1,117 @@ +import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; + +export const CODE_HASH_PARAM = 'code'; +export const CSS_HASH_PARAM = 'css'; +/** Relative to Storybook's `iframe.html`: shell under `/playground/app`, runtime under `/playground/runtime`. */ +export const PLAYGROUND_PATH = 'playground/app/playground.html'; + +export interface CssModuleSource { + name: string; + source: string; +} + +export interface PlaygroundUrlState { + code: string; + cssModules: CssModuleSource[]; +} + +/** + * Compresses source code into a URL safe string. + */ +export function encodeCode(code: string): string { + return compressToEncodedURIComponent(code); +} + +/** + * Inverse of {@link encodeCode}. Returns `null` when the payload is missing or cannot be decoded. + */ +export function decodeCode(encoded: string): string | null { + if (!encoded) { + return null; + } + + const decoded = decompressFromEncodedURIComponent(encoded); + + return typeof decoded === 'string' && decoded.length > 0 ? decoded : null; +} + +function parseCssModulesPayload(encoded: string | null): CssModuleSource[] { + if (!encoded) { + return []; + } + + const json = decodeCode(encoded); + if (!json) { + return []; + } + + try { + const parsed: unknown = JSON.parse(json); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter( + (entry): entry is CssModuleSource => + Boolean(entry) && typeof entry.name === 'string' && typeof entry.source === 'string', + ); + } catch { + return []; + } +} + +/** + * Reads the playground payload from a `location.hash` value (`#code=...&css=...`). + */ +export function decodePlaygroundStateFromHash(hash: string): PlaygroundUrlState | null { + const params = new URLSearchParams(hash.replace(/^#/, '')); + const encoded = params.get(CODE_HASH_PARAM); + const code = encoded ? decodeCode(encoded) : null; + + if (!code) { + return null; + } + + return { code, cssModules: parseCssModulesPayload(params.get(CSS_HASH_PARAM)) }; +} + +/** + * Reads the code payload from a `location.hash` value (`#code=...`). + */ +export function decodeCodeFromHash(hash: string): string | null { + return decodePlaygroundStateFromHash(hash)?.code ?? null; +} + +/** + * Creates a `location.hash` value for provided playground state. + */ +export function createPlaygroundHash(state: { code: string; cssModules?: readonly CssModuleSource[] }): string { + let hash = `#${CODE_HASH_PARAM}=${encodeCode(state.code)}`; + + if (state.cssModules && state.cssModules.length > 0) { + hash += `&${CSS_HASH_PARAM}=${encodeCode(JSON.stringify(state.cssModules))}`; + } + + return hash; +} + +/** + * Creates a `location.hash` value for provided code. + */ +export function createCodeHash(code: string): string { + return createPlaygroundHash({ code }); +} + +/** + * Creates the playground URL for provided code (and optional CSS modules from the story). + * + * The URL is relative to the Storybook preview (`iframe.html`), which lives next to the playground static dirs in the + * Storybook root, so it also works when Storybook is deployed under a sub path. + */ +export function createPlaygroundUrl( + code: string, + baseUrl = `./${PLAYGROUND_PATH}`, + cssModules?: readonly CssModuleSource[], +): string { + return `${baseUrl}${createPlaygroundHash({ code, cssModules })}`; +} diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts new file mode 100644 index 0000000000000..d527fcb7b0342 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.spec.ts @@ -0,0 +1,65 @@ +import { buildRuntimeEntrySource, filterRuntimeEntryAssets } from './webpack'; + +describe('filterRuntimeEntryAssets', () => { + it('removes playground-only assets from Storybook HTML and preserves shared chunks', () => { + const entrypoints = new Map([ + [ + 'main', + { + getFiles: () => ['runtime~main.js', 'shared.js', 'main.js', 'shared.css'], + }, + ], + [ + 'playground-runtime', + { + getFiles: () => [ + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'shared.js', + 'playground-runtime.js', + 'shared.css', + 'playground.css', + ], + }, + ], + ]); + const data = { + assets: { + js: [ + 'runtime~main.js', + 'shared.js', + 'main.js', + 'runtime~playground-runtime.js', + 'react-dom-client.js', + 'playground-runtime.js', + ], + css: ['shared.css', 'playground.css'], + }, + }; + + expect(filterRuntimeEntryAssets(entrypoints, data)).toEqual({ + assets: { + js: ['runtime~main.js', 'shared.js', 'main.js'], + css: ['shared.css'], + }, + }); + }); +}); + +describe('buildRuntimeEntrySource', () => { + it('eagerly imports configured modules instead of using dynamic import()', () => { + const source = buildRuntimeEntrySource({ + modules: { + '@fluentui/react-components': '@fluentui/react-components', + '@fluentui/react-icons': '@fluentui/react-icons', + }, + setup: '/abs/playground.setup.tsx', + }); + + expect(source).toContain('import * as __pg_mod_0 from "@fluentui/react-components";'); + expect(source).toContain('import * as __pg_mod_1 from "@fluentui/react-icons";'); + expect(source).toContain('"@fluentui/react-components": () => Promise.resolve(__pg_mod_0)'); + expect(source).toContain('"@fluentui/react-icons": () => Promise.resolve(__pg_mod_1)'); + expect(source).not.toMatch(/\bimport\(/); + }); +}); diff --git a/packages/react-components/react-storybook-addon-playground/src/webpack.ts b/packages/react-components/react-storybook-addon-playground/src/webpack.ts new file mode 100644 index 0000000000000..df2a17e8edbf0 --- /dev/null +++ b/packages/react-components/react-storybook-addon-playground/src/webpack.ts @@ -0,0 +1,304 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { PresetConfig } from './public-types'; + +type WebpackFinalFn = NonNullable; +type WebpackFinalConfig = Parameters[0]; +type WebpackFinalOptions = Parameters[1]; +type CollectTypingsResult = { + files: Record; + sources: string[]; + missing: string[]; +}; +type HtmlAssetsData = { + assets: { + js: string[]; + css: string[]; + }; +}; +type EntrypointFiles = { + getFiles(): string[]; +}; +type HtmlWebpackPluginConstructor = { + getHooks(compilation: import('webpack').Compilation): { + beforeAssetTagGeneration: { + tap(name: string, callback: (data: HtmlAssetsData) => HtmlAssetsData): void; + }; + }; +}; + +export const ENTRY_NAME = 'playground-runtime'; +export const REGISTER_CALLBACK = '__FLUENTUI_PLAYGROUND_REGISTER_V1__'; + +const addonFilePattern = /react-storybook-addon-playground\/[a-z/]+.[jt]s$/; +const defaultOptions: PresetConfig = { + modules: {}, +}; + +const { collectTypings } = require('../tools/collect-typings') as { + collectTypings(options: { packageRoot: string; entries: string[]; typescriptVersion: string }): CollectTypingsResult; +}; + +/** + * Storybook preset hook: emits a separate Webpack entry for the playground runtime (configured modules + setup), + * collects Monaco typings for those modules, and writes `playground/runtime/manifest.json`. + */ +export function webpackFinal(config: WebpackFinalConfig, options: WebpackFinalOptions): WebpackFinalConfig { + const addonOptions = getAddonOptions(options); + const runtimeEntry = writeRuntimeEntry(addonOptions, options.configDir ?? process.cwd()); + const typings = collectConfiguredTypings(addonOptions, options); + const originalEntry = config.entry; + + config.entry = async (): Promise => { + const entry = typeof originalEntry === 'function' ? await originalEntry() : originalEntry; + const normalizedEntry: import('webpack').EntryObject = + !entry || typeof entry === 'string' || Array.isArray(entry) ? { main: entry ?? [] } : entry; + + return { + ...normalizedEntry, + [ENTRY_NAME]: runtimeEntry, + }; + }; + + config.plugins = config.plugins ?? []; + config.plugins.push(new ExcludeRuntimeEntryFromHtmlPlugin()); + config.plugins.push(new PlaygroundRuntimeManifestPlugin(addonOptions, typings)); + + return config; +} + +/** + * Storybook injects every Webpack entry into `iframe.html` by default. The playground runtime must only load inside + * the sandboxed preview iframe. + */ +class ExcludeRuntimeEntryFromHtmlPlugin { + public apply(compiler: import('webpack').Compiler): void { + const pluginName = 'ExcludePlaygroundRuntimeFromHtmlPlugin'; + const htmlPlugin = compiler.options.plugins.find( + plugin => plugin && plugin.constructor.name === 'HtmlWebpackPlugin', + ); + const htmlPluginConstructor = htmlPlugin?.constructor as unknown as HtmlWebpackPluginConstructor | undefined; + + if (!htmlPluginConstructor?.getHooks) { + return; + } + + compiler.hooks.compilation.tap(pluginName, compilation => { + htmlPluginConstructor.getHooks(compilation).beforeAssetTagGeneration.tap(pluginName, data => { + return filterRuntimeEntryAssets(compilation.entrypoints, data); + }); + }); + } +} + +export function filterRuntimeEntryAssets( + entrypoints: ReadonlyMap, + data: HtmlAssetsData, +): HtmlAssetsData { + const runtimeFiles = new Set(entrypoints.get(ENTRY_NAME)?.getFiles() ?? []); + const otherEntryFiles = new Set( + Array.from(entrypoints.entries()).flatMap(([name, entrypoint]) => + name === ENTRY_NAME ? [] : entrypoint.getFiles(), + ), + ); + const exclusiveRuntimeFiles = Array.from(runtimeFiles).filter(file => !otherEntryFiles.has(file)); + + data.assets.js = data.assets.js.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + data.assets.css = data.assets.css.filter(asset => !matchesAnyAsset(asset, exclusiveRuntimeFiles)); + + return data; +} + +function matchesAnyAsset(assetUrl: string, assetFiles: string[]): boolean { + const normalizedUrl = assetUrl.split(/[?#]/, 1)[0].replace(/\\/g, '/'); + + return assetFiles.some(file => { + const normalizedFile = file.replace(/\\/g, '/'); + return normalizedUrl === normalizedFile || normalizedUrl.endsWith(`/${normalizedFile}`); + }); +} + +function getAddonOptions(options: WebpackFinalOptions): PresetConfig { + const presetRegistration = options.presetsList?.find(preset => addonFilePattern.test(preset.name)); + const addonOptions = presetRegistration?.options ?? {}; + + return { ...defaultOptions, ...addonOptions }; +} + +function getDefaultSetupPath(): string { + // Compiled next to this file (`lib-commonjs/webpack.js` → `lib-commonjs/defaultSetup.js`) + return require.resolve('./defaultSetup'); +} + +/** + * Builds the playground runtime entry source. + * + * Configured modules are **static** imports (not `import()`). Dynamic imports become async chunks that Storybook's + * webpack lazy-compilation serves from a separate origin — which fails inside `sandbox="allow-scripts"` (opaque + * origin) iframes. Static imports stay on the entrypoint graph and ship via the manifest `