diff --git a/.changeset/config.json b/.changeset/config.json index da8e3264..58431989 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/@changesets/config@4.0.0/schema.json", "changelog": [ "@changesets/changelog-github", - { "repo": "FormidableLabs/react-live" } + { "repo": "FormidableLabs/react-live", "disableThanks": true } ], "access": "public", "baseBranch": "master", diff --git a/.changeset/olive-otters-listen.md b/.changeset/olive-otters-listen.md new file mode 100644 index 00000000..2f8dc136 --- /dev/null +++ b/.changeset/olive-otters-listen.md @@ -0,0 +1,21 @@ +--- +"react-live": major +--- + +Type the `live` prop that `withLive` injects, instead of `Record`. + +Wrapped components saw `unknown` for every key, so `live.element` could not be +rendered and `live.error` could not be read without casting first. The prop now +uses the same type the context is created with, which is the shape +[the docs](https://github.com/FormidableLabs/react-live/blob/main/docs/api.md) +have always described: `code`, `error`, `element`, `onError`, `onChange`. + +```tsx +const Panel = withLive(({ live }) => { + const Result = live.element; // was `unknown`, now a component + return Result ? :
{live.error}
; +}); +``` + +Types only, with no runtime change. It is a narrowing, so a wrapped component +that reads a key outside that shape off `live` will now fail to compile. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83618f04..6f072ea0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,24 +63,49 @@ Two things to know when writing tests: ## Stories Stories live in [`packages/react-live/stories`](packages/react-live/stories) as -`*.stories.jsx`. A story is an object: +`*.stories.tsx`. A story names a component and the props to render it with: -```jsx +```tsx export const title = "Live"; -export const Inline = { +export const Inline = story(LiveProvider, { args: { code: "Hello World!" }, - render: (args) => , -}; +}); ``` -Add a file matching `*.stories.jsx` and it appears in the sidebar automatically. The dev +`args` is typed as that component's props, so a renamed, missing, or mistyped prop fails +`npm run typecheck` instead of surfacing during a manual browse. Pass `render` only when a +scenario needs more than a single element — local state, sibling markup, a different +composition: + +```tsx +export const TabFocus = story(Editor, { + args: { + code: "// press Tab to leave", + language: "javascript", + tabMode: "focus", + }, + render: (args) => ( + <> + + + + ), +}); +``` + +Add a file matching `*.stories.tsx` and it appears in the sidebar automatically. The dev server aliases `react-live` to source, so library edits hot-reload with no build step. -`npm run stories:test` smoke-renders every story through the same glob, so a broken story +`npm run stories:test` smoke-renders every story through the same loader, so a broken story fails CI. A story that throws on purpose sets `expectsError: true`. +Two modules back the harness, kept apart deliberately: `story.ts` defines a story and +`load.ts` discovers them. Merging them makes the eager glob import the story files while +`story()` is still initialising, which fails at import time — where no type-check would +have caught it. + ## Changesets If your change affects published behaviour, add a changeset and commit it with your PR: diff --git a/packages/react-live/package.json b/packages/react-live/package.json index 2ffe7bcc..0741c112 100644 --- a/packages/react-live/package.json +++ b/packages/react-live/package.json @@ -49,7 +49,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.stories.json" }, "dependencies": { "prism-react-renderer": "^2.4.1", diff --git a/packages/react-live/src/components/Live/LiveContext.ts b/packages/react-live/src/components/Live/LiveContext.ts index e2300231..ff9a3ce9 100644 --- a/packages/react-live/src/components/Live/LiveContext.ts +++ b/packages/react-live/src/components/Live/LiveContext.ts @@ -1,7 +1,7 @@ import { themes } from "prism-react-renderer"; import { ComponentType, createContext } from "react"; -type ContextValue = { +export type LiveContextValue = { error?: string; element?: ComponentType | null; code: string; @@ -13,6 +13,6 @@ type ContextValue = { onChange(value: string): void; }; -const LiveContext = createContext({} as ContextValue); +const LiveContext = createContext({} as LiveContextValue); export default LiveContext; diff --git a/packages/react-live/src/hoc/withLive.tsx b/packages/react-live/src/hoc/withLive.tsx index 713c303f..d5044561 100644 --- a/packages/react-live/src/hoc/withLive.tsx +++ b/packages/react-live/src/hoc/withLive.tsx @@ -1,8 +1,12 @@ import React, { ComponentType } from "react"; import LiveContext from "../components/Live/LiveContext"; +import type { LiveContextValue } from "../components/Live/LiveContext"; +// The context already describes itself precisely; `Record` +// handed every wrapped component an `unknown` for `live.element` and +// `live.error`, neither of which is usable without a cast. type Props = { - live: Record; + live: LiveContextValue; }; export default function withLive( diff --git a/packages/react-live/stories/editor.stories.jsx b/packages/react-live/stories/editor.stories.tsx similarity index 70% rename from packages/react-live/stories/editor.stories.jsx rename to packages/react-live/stories/editor.stories.tsx index 4d6288da..6eb8b13f 100644 --- a/packages/react-live/stories/editor.stories.jsx +++ b/packages/react-live/stories/editor.stories.tsx @@ -1,68 +1,63 @@ import { themes } from "prism-react-renderer"; import { Editor } from "react-live"; +import { story } from "./story"; + export const title = "Editor"; -export const Default = { +export const Default = story(Editor, { args: { code: "const greeting = 'Hello World!';", language: "javascript" }, - render: (args) => , -}; +}); -export const MultiLine = { +export const MultiLine = story(Editor, { args: { code: "function add(a, b) {\n return a + b;\n}\n\nadd(1, 2);", language: "javascript", }, - render: (args) => , -}; +}); -export const TypeScript = { +export const TypeScript = story(Editor, { args: { code: "const add = (a: number, b: number): number => a + b;", language: "typescript", }, - render: (args) => , -}; +}); -export const CustomFontFamily = { +export const CustomFontFamily = story(Editor, { args: { code: "const greeting = 'Hello World!';", language: "javascript", style: { fontFamily: "'Courier New', monospace", fontSize: 18 }, }, - render: (args) => , -}; +}); -export const LightTheme = { +export const LightTheme = story(Editor, { args: { code: "const greeting = 'Hello World!';", language: "javascript", theme: themes.github, }, - render: (args) => , -}; +}); -export const Disabled = { +export const Disabled = story(Editor, { args: { code: "// read only", language: "javascript", disabled: true, }, - render: (args) => , -}; +}); /** Tab inserts two spaces. This is the default. */ -export const TabIndentation = { +export const TabIndentation = story(Editor, { args: { code: "function indented() {\n return true;\n}", language: "javascript", tabMode: "indentation", }, - render: (args) => , -}; +}); /** Tab moves focus out of the editor instead of indenting -- better for a11y. */ -export const TabFocus = { +export const TabFocus = story(Editor, { args: { code: "// press Tab to leave the editor", language: "javascript", @@ -74,4 +69,4 @@ export const TabFocus = { ), -}; +}); diff --git a/packages/react-live/stories/index.html b/packages/react-live/stories/index.html index b8829b84..27a2d8d6 100644 --- a/packages/react-live/stories/index.html +++ b/packages/react-live/stories/index.html @@ -7,6 +7,6 @@
- + diff --git a/packages/react-live/stories/live.stories.jsx b/packages/react-live/stories/live.stories.tsx similarity index 78% rename from packages/react-live/stories/live.stories.jsx rename to packages/react-live/stories/live.stories.tsx index e813467d..baa9270a 100644 --- a/packages/react-live/stories/live.stories.jsx +++ b/packages/react-live/stories/live.stories.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import type { ComponentProps } from "react"; import { themes } from "prism-react-renderer"; import { LiveProvider, @@ -8,10 +9,12 @@ import { withLive, } from "react-live"; +import { story } from "./story"; + export const title = "Live"; /** The standard editor + preview + error layout. */ -const Playground = (props) => ( +const Playground = (props: ComponentProps) => ( @@ -19,17 +22,15 @@ const Playground = (props) => ( ); -export const Inline = { +export const Inline = story(Playground, { args: { code: "\n Hello World!\n Next Indent Level\n" }, - render: (args) => , -}; +}); -export const FunctionComponent = { +export const FunctionComponent = story(Playground, { args: { code: "() => (\n

\n So functional. Much wow!\n

\n)" }, - render: (args) => , -}; +}); -export const NoInline = { +export const NoInline = story(Playground, { args: { noInline: true, code: `const Counter = () => { @@ -43,10 +44,9 @@ export const NoInline = { } render()`, }, - render: (args) => , -}; +}); -export const ClassComponent = { +export const ClassComponent = story(Playground, { args: { code: `class Counter extends React.Component { constructor() { @@ -62,10 +62,9 @@ export const ClassComponent = { } }`, }, - render: (args) => , -}; +}); -export const TypeScriptComponent = { +export const TypeScriptComponent = story(Playground, { args: { noInline: true, code: `interface Props { name: string } @@ -74,20 +73,18 @@ const Greeting = ({ name }: Props) =>

Hello {name}

render()`, }, - render: (args) => , -}; +}); -export const TypeScriptDisabled = { +export const TypeScriptDisabled = story(Playground, { args: { enableTypeScript: false, noInline: true, code: `const greet = (name: string) => name render(

{greet("this should error")}

)`, }, - render: (args) => , -}; +}); -export const Hooks = { +export const Hooks = story(Playground, { args: { code: `function LikeButton() { const [likes, increaseLikes] = React.useState(0) @@ -99,58 +96,51 @@ export const Hooks = { ) }`, }, - render: (args) => , -}; +}); -export const WithScope = { +export const WithScope = story(Playground, { args: { code: "

{greeting} from scope

", scope: { greeting: "Hello" }, }, - render: (args) => , -}; +}); -export const WithTheme = { +export const WithTheme = story(Playground, { args: { code: "Hello World!", theme: themes.github, }, - render: (args) => , -}; +}); -export const SyntaxError = { +export const SyntaxError = story(Playground, { args: { code: "
" }, - render: (args) => , -}; +}); -export const RuntimeError = { +export const RuntimeError = story(Playground, { args: { code: "() => { throw new Error('boom') }" }, // Throwing is the point of this story. The flag tells the smoke test to // expect the console noise React emits for a caught render error, so a // *genuine* error in any other story still stands out. expectsError: true, - render: (args) => , -}; +}); -export const Disabled = { +export const Disabled = story(Playground, { args: { code: "You cannot edit me", disabled: true }, - render: (args) => , -}; +}); -export const TransformCode = { +export const TransformCode = story(Playground, { args: { code: "Hello World!", noInline: true, transformCode: (code) => `render(

${code}

)`, }, - render: (args) => , -}; +}); /** A custom editing surface driven by the same provider. */ -export const CustomEditor = { +export const CustomEditor = story(LiveProvider, { args: { code: "

Edit me in the textarea

" }, render: ({ code: initial }) => { - const [code, setCode] = useState(initial); + const [code, setCode] = useState(initial ?? ""); return (