diff --git a/addons/ondevice-actions/package.json b/addons/ondevice-actions/package.json index c1798d44e7..80ba296477 100644 --- a/addons/ondevice-actions/package.json +++ b/addons/ondevice-actions/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-ondevice-actions", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "description": "Action Logger addon for react-native storybook", "keywords": [ "storybook" @@ -26,15 +26,15 @@ "prepare": "node ../../scripts/prepare.js" }, "dependencies": { - "@storybook/addons": "~6.3", - "@storybook/core-events": "~6.3", + "@storybook/addons": "^6.5", + "@storybook/core-events": "^6.5", "fast-deep-equal": "^2.0.1" }, "devDependencies": { - "@storybook/addon-actions": "~6.3" + "@storybook/addon-actions": "^6.5" }, "peerDependencies": { - "@storybook/addon-actions": "~6.3", + "@storybook/addon-actions": "^6.5", "react": "*", "react-native": "*" }, diff --git a/addons/ondevice-backgrounds/package.json b/addons/ondevice-backgrounds/package.json index 82cc1fedcb..17baf74516 100644 --- a/addons/ondevice-backgrounds/package.json +++ b/addons/ondevice-backgrounds/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-ondevice-backgrounds", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "description": "A react-native storybook addon to show different backgrounds for your preview", "keywords": [ "addon", @@ -31,9 +31,9 @@ "prepare": "node ../../scripts/prepare.js" }, "dependencies": { - "@storybook/addons": "~6.3", - "@storybook/api": "~6.3", - "@storybook/client-api": "~6.3", + "@storybook/addons": "^6.5", + "@storybook/api": "^6.5", + "@storybook/client-api": "^6.5", "core-js": "^3.0.1", "prop-types": "^15.7.2" }, diff --git a/addons/ondevice-backgrounds/src/index.tsx b/addons/ondevice-backgrounds/src/index.tsx index 9c2a52bf4c..be620609cd 100644 --- a/addons/ondevice-backgrounds/src/index.tsx +++ b/addons/ondevice-backgrounds/src/index.tsx @@ -33,7 +33,7 @@ export const withBackgrounds = makeDecorator({ return ( - {getStory(context)} + {getStory(context) as React.ReactNode} ); }, diff --git a/addons/ondevice-controls/package.json b/addons/ondevice-controls/package.json index e7e99a563e..c76a1acde1 100644 --- a/addons/ondevice-controls/package.json +++ b/addons/ondevice-controls/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-ondevice-controls", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "description": "Display storybook controls on your device.", "keywords": [ "addon", @@ -30,9 +30,9 @@ }, "dependencies": { "@emotion/native": "^10.0.14", - "@storybook/addons": "~6.3", - "@storybook/client-logger": "~6.3", - "@storybook/core-events": "~6.3", + "@storybook/addons": "^6.5", + "@storybook/client-logger": "^6.5", + "@storybook/core-events": "^6.5", "core-js": "^3.0.1", "deep-equal": "^1.0.1", "prop-types": "^15.7.2", @@ -41,13 +41,13 @@ "tinycolor2": "^1.4.1" }, "devDependencies": { - "@storybook/addon-knobs": "~6.3", + "@storybook/addon-controls": "^6.5", "@types/react-native": "^0.70.4" }, "peerDependencies": { "@react-native-community/datetimepicker": "*", "@react-native-community/slider": "*", - "@storybook/addon-controls": "~6.3", + "@storybook/addon-controls": "^6.5", "react": "*", "react-native": "*" }, diff --git a/addons/ondevice-controls/src/hooks.ts b/addons/ondevice-controls/src/hooks.ts index 5ec61ce3cb..721c6a72bc 100644 --- a/addons/ondevice-controls/src/hooks.ts +++ b/addons/ondevice-controls/src/hooks.ts @@ -1,24 +1,11 @@ import { Args } from '@storybook/addons'; import { useState, useEffect, useCallback } from 'react'; -import Events, { FORCE_RE_RENDER } from '@storybook/core-events'; -import useDebouncedCallback from './useDebounceCallback'; +import Events from '@storybook/core-events'; export const useArgs = ( storyId: string, storyStore: any ): [Args, (args: Args) => void, (argNames?: string[]) => void] => { - // TODO: remove need to force re-render - const debouncedReRender = useDebouncedCallback( - () => storyStore._channel.emit(FORCE_RE_RENDER), - 100 - ); - - useEffect(() => { - return () => { - debouncedReRender.cancel(); - }; - }, [debouncedReRender]); - const story = storyStore.fromId(storyId); if (!story) { throw new Error(`Unknown story: ${storyId}`); @@ -38,18 +25,13 @@ export const useArgs = ( const updateArgs = useCallback( (newArgs) => { - storyStore.updateStoryArgs(storyId, newArgs); - - //TODO: remove this if possible - debouncedReRender(); + storyStore._channel.emit(Events.UPDATE_STORY_ARGS, { storyId, updatedArgs: newArgs }); }, - [debouncedReRender, storyId, storyStore] + [storyId, storyStore] ); const resetArgs = useCallback( (argNames?: string[]) => { - storyStore.resetStoryArgs(storyId, argNames); - //TODO: remove this if possible - storyStore._channel.emit(FORCE_RE_RENDER); + storyStore._channel.emit(Events.RESET_STORY_ARGS, { storyId, argNames }); }, [storyId, storyStore] ); diff --git a/addons/ondevice-controls/src/types/Array.tsx b/addons/ondevice-controls/src/types/Array.tsx index 9104a94b4c..6540b94458 100644 --- a/addons/ondevice-controls/src/types/Array.tsx +++ b/addons/ondevice-controls/src/types/Array.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from '@emotion/native'; +import { useResyncValue } from './useResyncValue'; const Input = styled.TextInput(({ theme }) => ({ borderWidth: 1, @@ -25,20 +26,30 @@ export interface ArrayProps { separator: string; }; onChange: (value: string[]) => void; + isPristine: boolean; } const ArrayType = ({ arg: { name, value, separator = ',' }, onChange = () => null, -}: ArrayProps) => ( - onChange(formatArray(e, separator))} - /> -); + isPristine, +}: ArrayProps) => { + const { setCurrentValue, key } = useResyncValue(value, isPristine); + return ( + { + const formatted = formatArray(text, separator); + onChange(formatted); + setCurrentValue(formatted); + }} + /> + ); +}; ArrayType.serialize = (value) => value; diff --git a/addons/ondevice-controls/src/types/Number.tsx b/addons/ondevice-controls/src/types/Number.tsx index d966d03763..8aef15777d 100644 --- a/addons/ondevice-controls/src/types/Number.tsx +++ b/addons/ondevice-controls/src/types/Number.tsx @@ -1,7 +1,8 @@ import styled from '@emotion/native'; import Slider from '@react-native-community/slider'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Platform, StyleSheet, View } from 'react-native'; +import { useResyncValue } from './useResyncValue'; const Input = styled.TextInput(({ theme }) => ({ borderWidth: 1, @@ -25,7 +26,7 @@ const ValueLabelText = styled.Text(({ theme }) => ({ })); const ValueContainer = styled.View({ flexDirection: 'row' }); - +// @ts-ignore styled is being weird ;( const NumberSlider = styled(Slider)(() => ({ marginTop: Platform.OS === 'android' ? 8 : 4, marginLeft: Platform.OS === 'android' ? -10 : 0, @@ -49,6 +50,8 @@ export interface NumberProps { const NumberType = ({ arg, isPristine, onChange = (value) => value }: NumberProps) => { const showError = Number.isNaN(arg.value); const [numStr, setNumStr] = useState(arg.value.toString()); + const updateNumstr = useCallback((value) => setNumStr(value.toString()), []); + const { key, setCurrentValue } = useResyncValue(arg.value, isPristine, updateNumstr); const handleNormalChangeText = (text: string) => { const commaReplaced = text.trim().replace(/,/, '.'); @@ -56,18 +59,13 @@ const NumberType = ({ arg, isPristine, onChange = (value) => value }: NumberProp setNumStr(commaReplaced); if (commaReplaced === '-') { onChange(-1); + setCurrentValue(-1); } else { onChange(Number(commaReplaced)); + setCurrentValue(Number(commaReplaced)); } }; - // handle arg.value and numStr out of sync issue on reset - useEffect(() => { - if (isPristine) { - setNumStr(arg.value.toString()); - } - }, [isPristine, arg.value]); - const renderNormal = () => { return ( value }: NumberProp const renderRange = (): React.ReactNode => { return ( - <> + Value: {arg.value} @@ -92,9 +90,12 @@ const NumberType = ({ arg, isPristine, onChange = (value) => value }: NumberProp minimumValue={arg.min} maximumValue={arg.max} step={arg.step} - onValueChange={(val) => onChange(val)} + onValueChange={(val) => { + onChange(val); + setCurrentValue(val); + }} /> - + ); }; diff --git a/addons/ondevice-controls/src/types/Object.tsx b/addons/ondevice-controls/src/types/Object.tsx index 175886584a..cdfbfe7895 100644 --- a/addons/ondevice-controls/src/types/Object.tsx +++ b/addons/ondevice-controls/src/types/Object.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useState } from 'react'; import styled from '@emotion/native'; import { ViewStyle } from 'react-native'; +import { useResyncValue } from './useResyncValue'; export interface ObjectProps { arg: { @@ -8,6 +9,7 @@ export interface ObjectProps { value: Record | Array; }; onChange: (value: any) => void; + isPristine: boolean; } const Input = styled.TextInput(({ theme }) => ({ @@ -21,7 +23,7 @@ const Input = styled.TextInput(({ theme }) => ({ minHeight: 60, })); -const ObjectType = ({ arg, onChange }: ObjectProps) => { +const ObjectType = ({ arg, onChange, isPristine }: ObjectProps) => { const getJsonString = useCallback(() => { try { return JSON.stringify(arg.value, null, 2); @@ -31,6 +33,7 @@ const ObjectType = ({ arg, onChange }: ObjectProps) => { }, [arg.value]); const [failed, setFailed] = useState(false); + const { key, setCurrentValue } = useResyncValue(arg.value, isPristine); const handleChange = (value) => { const withReplacedQuotes = value @@ -42,6 +45,7 @@ const ObjectType = ({ arg, onChange }: ObjectProps) => { const json = JSON.parse(withReplacedQuotes.trim()); onChange(json); + setCurrentValue(json); setFailed(false); } catch (err) { setFailed(true); @@ -58,6 +62,7 @@ const ObjectType = ({ arg, onChange }: ObjectProps) => { return ( ({ borderColor: theme.borderColor || '#e6e6e6', })); +// @ts-ignore styled is being weird ;( const WebSelect = styled(Select)(({ theme }) => ({ border: 'none', color: theme.labelColor || 'black', diff --git a/addons/ondevice-controls/src/types/Text.tsx b/addons/ondevice-controls/src/types/Text.tsx index aa73504747..60f2c12bbc 100644 --- a/addons/ondevice-controls/src/types/Text.tsx +++ b/addons/ondevice-controls/src/types/Text.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from '@emotion/native'; +import { useResyncValue } from './useResyncValue'; export interface TextProps { arg: { @@ -8,6 +9,7 @@ export interface TextProps { type: string; }; onChange: (value: any) => void; + isPristine: boolean; } const Input = styled.TextInput(({ theme }) => ({ @@ -20,12 +22,17 @@ const Input = styled.TextInput(({ theme }) => ({ color: theme.labelColor || 'black', })); -const TextType = ({ arg, onChange }: TextProps) => { +const TextType = ({ arg, onChange, isPristine }: TextProps) => { + const { setCurrentValue, key } = useResyncValue(arg.value, isPristine); return ( { + onChange(text); + setCurrentValue(text); + }} autoCapitalize="none" underlineColorAndroid="transparent" /> diff --git a/addons/ondevice-controls/src/types/useResyncValue.ts b/addons/ondevice-controls/src/types/useResyncValue.ts new file mode 100644 index 0000000000..087356fb43 --- /dev/null +++ b/addons/ondevice-controls/src/types/useResyncValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; + +// syncs up the value of a control with the value of a story arg when resetting +// this is used for controls that don't use a controlled input like the slider +export function useResyncValue( + value: any, + isPristine: boolean, + resyncCallback?: (syncValue: any) => void +) { + const [key, setKey] = useState(0); + const [currentValue, setCurrentValue] = useState(value); + + useEffect(() => { + if (isPristine && value !== currentValue) { + setKey((cur) => cur + 1); + resyncCallback?.(value); + } + }, [value, currentValue, isPristine, resyncCallback]); + return { key, setCurrentValue }; +} diff --git a/addons/ondevice-notes/package.json b/addons/ondevice-notes/package.json index 7380618fe2..5e130044b7 100644 --- a/addons/ondevice-notes/package.json +++ b/addons/ondevice-notes/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/addon-ondevice-notes", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "description": "Write notes for your react-native Storybook stories.", "keywords": [ "addon", @@ -29,11 +29,11 @@ }, "dependencies": { "@emotion/core": "^10.0.20", - "@storybook/addons": "~6.3", - "@storybook/api": "~6.3", - "@storybook/client-api": "~6.3", - "@storybook/client-logger": "~6.3", - "@storybook/core-events": "~6.3", + "@storybook/addons": "^6.5", + "@storybook/api": "^6.5", + "@storybook/client-api": "^6.5", + "@storybook/client-logger": "^6.5", + "@storybook/core-events": "^6.5", "core-js": "^3.0.1", "prop-types": "^15.7.2", "simple-markdown": "^0.7.3" diff --git a/app/react-native-server/package.json b/app/react-native-server/package.json index 52b72eeb36..bb54a0d754 100644 --- a/app/react-native-server/package.json +++ b/app/react-native-server/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-native-server", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "private": "true", "description": "A better way to develop React Native Components for your app", "keywords": [ @@ -32,12 +32,12 @@ "prepare": "node ../../scripts/prepare.js" }, "dependencies": { - "@storybook/addons": "~6.3", - "@storybook/api": "~6.3", - "@storybook/channel-websocket": "~6.3", - "@storybook/core": "~6.3", - "@storybook/core-events": "~6.3", - "@storybook/ui": "~6.3", + "@storybook/addons": "^6.5", + "@storybook/api": "^6.5", + "@storybook/channel-websocket": "^6.5", + "@storybook/core": "^6.5", + "@storybook/core-events": "^6.5", + "@storybook/ui": "^6.5", "commander": "^8.2.0", "core-js": "^3.0.1", "global": "^4.3.2", diff --git a/app/react-native/package.json b/app/react-native/package.json index 7d14f57414..1d6d63d6f4 100644 --- a/app/react-native/package.json +++ b/app/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@storybook/react-native", - "version": "6.0.1-beta.8", + "version": "6.0.1-canary.3", "description": "A better way to develop React Native Components for your app", "keywords": [ "react", @@ -49,14 +49,15 @@ "dependencies": { "@emotion/core": "^10.0.20", "@emotion/native": "^10.0.14", - "@storybook/addons": "~6.3", - "@storybook/channel-websocket": "~6.3", - "@storybook/channels": "~6.3", - "@storybook/client-api": "~6.3", - "@storybook/client-logger": "~6.3", - "@storybook/core-client": "~6.3", - "@storybook/core-events": "~6.3", - "@storybook/csf": "0.0.1", + "@storybook/addons": "^6.5", + "@storybook/channel-websocket": "^6.5", + "@storybook/channels": "^6.5", + "@storybook/client-api": "^6.5", + "@storybook/client-logger": "^6.5", + "@storybook/core-client": "^6.5", + "@storybook/core-events": "^6.5", + "@storybook/csf": "0.0.2--canary.7c6c115.0", + "@storybook/preview-web": "^6.5", "chokidar": "^3.5.1", "commander": "^8.2.0", "emotion-theming": "^10.0.19", diff --git a/app/react-native/src/document-polyfill/DOM/Document.js b/app/react-native/src/document-polyfill/DOM/Document.js new file mode 100644 index 0000000000..ab2bf88a45 --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/Document.js @@ -0,0 +1,42 @@ +import Element from './Element'; +import HTMLVideoElement from './HTMLVideoElement'; +import HTMLImageElement from './HTMLImageElement'; +import HTMLCanvasElement from './HTMLCanvasElement'; + +class Document extends Element { + constructor() { + super('#document'); + this.body = new Element('BODY'); + this.documentElement = new Element('HTML'); + this.readyState = 'complete'; + } + + createElement(tagName) { + switch ((tagName || '').toLowerCase()) { + case 'video': + return new HTMLVideoElement(tagName); + case 'img': + return new HTMLImageElement(tagName); + case 'canvas': + return new HTMLCanvasElement(tagName); + case 'iframe': + // Return nothing to keep firebase working. + return null; + default: + return new Element(tagName); + } + } + + createElementNS(tagName) { + const element = this.createElement(tagName); + element.toDataURL = () => ({}); + return element; + } + + getElementById(id) { + return new Element('div'); + } + location = { search: '' }; +} + +export default Document; diff --git a/app/react-native/src/document-polyfill/DOM/Element.js b/app/react-native/src/document-polyfill/DOM/Element.js new file mode 100644 index 0000000000..e8b9b3bea0 --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/Element.js @@ -0,0 +1,68 @@ +import Node from './Node'; + +class Element extends Node { + constructor(tagName) { + return super(tagName.toUpperCase()); + + // eslint-disable-next-line no-unreachable + this.doc = { + body: { + innerHTML: '', + }, + }; + } + + get tagName() { + return this.nodeName; + } + + setAttributeNS() {} + + get clientWidth() { + return this.innerWidth; + } + get clientHeight() { + return this.innerHeight; + } + + get offsetWidth() { + return this.innerWidth; + } + get offsetHeight() { + return this.innerHeight; + } + + get innerWidth() { + return window.innerWidth; + } + get innerHeight() { + return window.innerHeight; + } + + getContext(contextType, contextOptions, context) { + return { + fillText: (text, x, y, maxWidth) => ({}), + measureText: (text) => ({ + width: (text || '').split('').length * 6, + height: 24, + }), + fillRect: () => ({}), + drawImage: () => ({}), + getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 0]) }), + getContextAttributes: () => ({ + stencil: true, + }), + getExtension: () => ({ + loseContext: () => {}, + }), + putImageData: () => ({}), + createImageData: () => ({}), + }; + } + + get ontouchstart() { + return {}; + } +} + +export default Element; diff --git a/app/react-native/src/document-polyfill/DOM/HTMLCanvasElement.js b/app/react-native/src/document-polyfill/DOM/HTMLCanvasElement.js new file mode 100644 index 0000000000..96fcc277cd --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/HTMLCanvasElement.js @@ -0,0 +1,3 @@ +import Element from './Element'; +class HTMLCanvasElement extends Element {} +export default HTMLCanvasElement; diff --git a/app/react-native/src/document-polyfill/DOM/HTMLImageElement.js b/app/react-native/src/document-polyfill/DOM/HTMLImageElement.js new file mode 100644 index 0000000000..80caaac3a6 --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/HTMLImageElement.js @@ -0,0 +1,4 @@ +import Element from './Element'; + +class HTMLImageElement extends Element {} +export default HTMLImageElement; diff --git a/app/react-native/src/document-polyfill/DOM/HTMLVideoElement.js b/app/react-native/src/document-polyfill/DOM/HTMLVideoElement.js new file mode 100644 index 0000000000..b37e1552d3 --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/HTMLVideoElement.js @@ -0,0 +1,3 @@ +import Element from './Element'; +class HTMLVideoElement extends Element {} +export default HTMLVideoElement; diff --git a/app/react-native/src/document-polyfill/DOM/Node.js b/app/react-native/src/document-polyfill/DOM/Node.js new file mode 100644 index 0000000000..cba345ae69 --- /dev/null +++ b/app/react-native/src/document-polyfill/DOM/Node.js @@ -0,0 +1,40 @@ +class Node { + constructor(nodeName) { + this.addEventListener = this.addEventListener.bind(this); + this.removeEventListener = this.removeEventListener.bind(this); + + this.style = {}; + this.className = { + baseVal: '', + }; + this.nodeName = nodeName; + } + + get ownerDocument() { + return window.document; + } + + addEventListener(_eventName, _listener) {} + + removeEventListener(_eventName, _listener) {} + + appendChild() {} + insertBefore() {} + removeChild() {} + setAttributeNS() {} + + getBoundingClientRect() { + return { + left: 0, + top: 0, + right: window.innerWidth, + bottom: window.innerHeight, + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight, + }; + } +} + +export default Node; diff --git a/app/react-native/src/document-polyfill/index.js b/app/react-native/src/document-polyfill/index.js new file mode 100644 index 0000000000..3cbe812057 --- /dev/null +++ b/app/react-native/src/document-polyfill/index.js @@ -0,0 +1,3 @@ +// this is temporary until we can solve the preview web crashing the app by accessing document +// adjusted from expo/browser-polyfill to not require external dependencies https://github.com/expo/browser-polyfill +import './module'; diff --git a/app/react-native/src/document-polyfill/module.js b/app/react-native/src/document-polyfill/module.js new file mode 100644 index 0000000000..c6035e5e3f --- /dev/null +++ b/app/react-native/src/document-polyfill/module.js @@ -0,0 +1 @@ +// do nothing diff --git a/app/react-native/src/document-polyfill/module.native.js b/app/react-native/src/document-polyfill/module.native.js new file mode 100644 index 0000000000..5cefc58532 --- /dev/null +++ b/app/react-native/src/document-polyfill/module.native.js @@ -0,0 +1,2 @@ +import Document from './DOM/Document'; +window.document = window.document || new Document(); diff --git a/app/react-native/src/index.ts b/app/react-native/src/index.ts index b88045d353..7080bb7f61 100644 --- a/app/react-native/src/index.ts +++ b/app/react-native/src/index.ts @@ -1,27 +1,30 @@ +import './document-polyfill'; import { StoryApi } from '@storybook/addons'; import { ClientApi } from '@storybook/client-api'; import { ReactNode } from 'react'; -import Preview from './preview'; -export const preview = new Preview(); +import { start } from './preview/start'; +import type { ReactNativeFramework } from './types/types-6.0'; -const rawStoriesOf: ClientApi['storiesOf'] = preview.api().storiesOf.bind(preview); -export const setAddon: ClientApi['setAddon'] = preview.api().setAddon.bind(preview); -export const addDecorator: ClientApi['addDecorator'] = preview.api().addDecorator.bind(preview); -export const addParameters: ClientApi['addParameters'] = preview.api().addParameters.bind(preview); -export const addArgsEnhancer: ClientApi['addArgsEnhancer'] = preview - .api() - .addArgsEnhancer.bind(preview); +const { clientApi, configure, view } = start(); +export { configure }; -export const clearDecorators: ClientApi['clearDecorators'] = preview - .api() - .clearDecorators.bind(preview); -export const configure = preview.configure; -export const getStorybook: ClientApi['getStorybook'] = preview.api().getStorybook.bind(preview); -export const getStorybookUI = preview.getStorybookUI; -export const raw: ClientApi['raw'] = preview.api().raw.bind(preview); +type C = ClientApi; -export const storiesOf = (kind: string, module: NodeModule) => - rawStoriesOf(kind, module).addParameters({ framework: 'react-native' }) as StoryApi; +const rawStoriesOf: C['storiesOf'] = clientApi.storiesOf.bind(clientApi); +export const setAddon: C['setAddon'] = clientApi.setAddon.bind(clientApi); +export const addDecorator: C['addDecorator'] = clientApi.addDecorator.bind(clientApi); +export const addParameters: C['addParameters'] = clientApi.addParameters.bind(clientApi); +export const addArgsEnhancer: C['addArgsEnhancer'] = clientApi.addArgsEnhancer.bind(clientApi); +export const clearDecorators: C['clearDecorators'] = clientApi.clearDecorators.bind(clientApi); +export const getStorybook: C['getStorybook'] = clientApi.getStorybook.bind(clientApi); +export const raw: C['raw'] = clientApi.raw.bind(clientApi); -export * from './types-6.0'; +export const storiesOf = (kind: string, _module: NodeModule) => + rawStoriesOf(kind, { hot: () => {} } as any).addParameters({ + framework: 'react-native', + }) as StoryApi; + +export const getStorybookUI = view.getStorybookUI; + +export * from './types/types-6.0'; diff --git a/app/react-native/src/preview/Preview.tsx b/app/react-native/src/preview/Preview.tsx deleted file mode 100644 index ed3f377ded..0000000000 --- a/app/react-native/src/preview/Preview.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { addons } from '@storybook/addons'; -import Channel from '@storybook/channels'; -import { ClientApi, ConfigApi, StoryStore } from '@storybook/client-api'; -import { Loadable } from '@storybook/core-client'; -import Events from '@storybook/core-events'; -import { toId } from '@storybook/csf'; -import { ThemeProvider } from 'emotion-theming'; -import React from 'react'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import OnDeviceUI from './components/OnDeviceUI'; -import { theme } from './components/Shared/theme'; -import { loadCsf } from './loadCsf'; - -const STORAGE_KEY = 'lastOpenedStory'; - -interface AsyncStorage { - getItem: (key: string) => Promise; - setItem: (key: string, value: string) => Promise; -} - -type StoryKind = string -type StoryName = string - -type InitialSelection = `${StoryKind}--${StoryName}` | { - /** - * Kind is the default export name or the storiesOf("name") name - */ - kind: StoryKind; - - /** - * Name is the named export or the .add("name") name - */ - name: StoryName; -} - -export type Params = { - onDeviceUI?: boolean; - resetStorybook?: boolean; - disableWebsockets?: boolean; - query?: string; - host?: string; - port?: number; - secured?: boolean; - initialSelection?: InitialSelection; - shouldPersistSelection?: boolean; - tabOpen?: number; - isUIHidden?: boolean; - shouldDisableKeyboardAvoidingView?: boolean; - keyboardAvoidingViewVerticalOffset?: number; -} & { theme?: typeof theme }; - -export default class Preview { - _clientApi: ClientApi; - - _storyStore: StoryStore; - - _addons: any; - - _channel: Channel; - - _decorators: any[]; - - _asyncStorageStoryId: string; - - _configApi: ConfigApi; - - configure: (loadable: Loadable, m: NodeModule, showDeprecationWarning: boolean) => void; - - constructor() { - const channel = new Channel({ async: true }); - this._decorators = []; - this._storyStore = new StoryStore({ channel }); - this._clientApi = new ClientApi({ storyStore: this._storyStore }); - this._configApi = new ConfigApi({ storyStore: this._storyStore }); - this._channel = channel; - const configure = loadCsf({ - clientApi: this._clientApi, - storyStore: this._storyStore, - configApi: this._configApi, - }); - this.configure = (...args) => configure('react-native', ...args); - - addons.setChannel(channel); - } - - api = () => { - return this._clientApi; - }; - - getStorybookUI = (params: Partial = {}) => { - const { initialSelection, shouldPersistSelection = true } = params; - this._setInitialStory(initialSelection, shouldPersistSelection); - - this._channel.on(Events.SET_CURRENT_STORY, (d: { storyId: string }) => { - this._selectStoryEvent(d, shouldPersistSelection); - }); - - const { _storyStore } = this; - - addons.loadAddons(this._clientApi); - - const appliedTheme = { ...theme, ...params.theme }; - return () => ( - - - - - - ); - }; - - _setInitialStory = async (initialSelection?: InitialSelection, shouldPersistSelection = true) => { - const story = await this._getInitialStory(initialSelection, shouldPersistSelection); - - if (story) { - this._selectStory(story); - } - }; - - _getInitialStory = async (initialSelection?: InitialSelection, shouldPersistSelection = true) => { - let story: string = null; - const initialSelectionId = initialSelection === undefined - ? undefined - : typeof initialSelection === 'string' - ? initialSelection - : toId(initialSelection.kind, initialSelection.name); - - if (initialSelectionId !== undefined && this._checkStory(initialSelectionId)) { - story = initialSelectionId; - } else if (shouldPersistSelection) { - try { - let value = this._asyncStorageStoryId; - if (!value) { - value = JSON.parse(await AsyncStorage.getItem(STORAGE_KEY)); - this._asyncStorageStoryId = value; - } - - if (this._checkStory(value)) { - story = value; - } - } catch (e) { - // - } - } - - if (story) { - return this._getStory(story); - } - - const stories = this._storyStore.raw(); - if (stories && stories.length) { - return this._getStory(stories[0].id); - } - - return null; - }; - - _getStory(storyId: string) { - return this._storyStore.fromId(storyId); - } - - _selectStoryEvent({ storyId }: { storyId: string }, shouldPersistSelection) { - if (storyId) { - if (shouldPersistSelection) { - AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(storyId)).catch(() => {}); - } - - const story = this._getStory(storyId); - this._selectStory(story); - } - } - - _selectStory(story: any) { - this._storyStore.setSelection({ storyId: story.id, viewMode: 'story' }); - this._channel.emit(Events.SELECT_STORY, story); - } - - _checkStory(storyId: string) { - if (!storyId) { - return null; - } - - const story = this._getStory(storyId); - - if (story === null || story.storyFn === null) { - return null; - } - - return story; - } -} diff --git a/app/react-native/src/preview/View.tsx b/app/react-native/src/preview/View.tsx new file mode 100644 index 0000000000..da49acd375 --- /dev/null +++ b/app/react-native/src/preview/View.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState, useReducer } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { StoryIndex, SelectionSpecifier } from '@storybook/store'; +import { StoryContext, toId } from '@storybook/csf'; +import { addons } from '@storybook/addons'; +import { ThemeProvider } from 'emotion-theming'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import OnDeviceUI from './components/OnDeviceUI'; +import { theme } from './components/Shared/theme'; +import type { ReactNativeFramework } from '../types/types-6.0'; +import { PreviewWeb } from '@storybook/preview-web'; + +const STORAGE_KEY = 'lastOpenedStory'; + +interface AsyncStorage { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + +type StoryKind = string; +type StoryName = string; + +type InitialSelection = + | `${StoryKind}--${StoryName}` + | { + /** + * Kind is the default export name or the storiesOf("name") name + */ + kind: StoryKind; + + /** + * Name is the named export or the .add("name") name + */ + name: StoryName; + }; + +export type Params = { + // onDeviceUI?: boolean; + // resetStorybook?: boolean; // TODO: access all these params to see if they + // disableWebsockets?: boolean; + // query?: string; + // host?: string; + // port?: number; + // secured?: boolean; + initialSelection?: InitialSelection; + shouldPersistSelection?: boolean; + tabOpen?: number; + isUIHidden?: boolean; + shouldDisableKeyboardAvoidingView?: boolean; + keyboardAvoidingViewVerticalOffset?: number; +} & { theme?: typeof theme }; + +export class View { + _storyIndex: StoryIndex; + _setStory: (story: StoryContext) => void = () => {}; + _forceRerender: () => void; + _ready: boolean = false; + _preview: PreviewWeb; + _asyncStorageStoryId: string; + + constructor(preview: PreviewWeb) { + this._preview = preview; + } + _getInitialStory = async ({ + initialSelection, + shouldPersistSelection = true, + }: Partial = {}): Promise => { + if (initialSelection) { + if (typeof initialSelection === 'string') { + return { storySpecifier: initialSelection, viewMode: 'story' }; + } else { + return { + storySpecifier: toId(initialSelection.kind, initialSelection.name), + viewMode: 'story', + }; + } + } + + if (shouldPersistSelection) { + try { + let value = this._asyncStorageStoryId; + if (!value) { + value = await AsyncStorage.getItem(STORAGE_KEY); + this._asyncStorageStoryId = value; + } + + return { storySpecifier: value ?? '*', viewMode: 'story' }; + } catch (e) { + console.warn('storybook-log: error reading from async storage', e); + } + } + + return { storySpecifier: '*', viewMode: 'story' }; + }; + getStorybookUI = (params: Partial = {}) => { + const { shouldPersistSelection = true } = params; + const initialStory = this._getInitialStory(params); + + addons.loadAddons({ + store: () => ({ + fromId: (id) => + this._preview.storyStore.getStoryContext(this._preview.storyStore.fromId(id)), + getSelection: () => { + return this._preview.currentSelection; + }, + _channel: this._preview.channel, + }), + }); + + // eslint-disable-next-line consistent-this + const self = this; + + const appliedTheme = { ...theme, ...params.theme }; + return () => { + const [context, setContext] = useState>(); + const [, forceUpdate] = useReducer((x) => x + 1, 0); + useEffect(() => { + self._setStory = (newStory: StoryContext) => { + setContext(newStory); + if (shouldPersistSelection) { + AsyncStorage.setItem(STORAGE_KEY, newStory.id).catch((e) => { + console.warn('storybook-log: error writing to async storage', e); + }); + } + }; + self._forceRerender = () => forceUpdate(); + initialStory.then((story) => { + self._preview.urlStore.selectionSpecifier = story; + self._preview.selectSpecifiedStory(); + }); + }, []); + + return ( + + + + + + ); + }; + }; +} diff --git a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx index 0abc7f52de..7763b56cde 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx +++ b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx @@ -1,7 +1,6 @@ import styled from '@emotion/native'; -import { addons } from '@storybook/addons'; -import { StoryStore } from '@storybook/client-api'; -import React, { useState, useEffect, useRef, useReducer } from 'react'; +import { StoryIndex } from '@storybook/client-api'; +import React, { useState, useRef } from 'react'; import { Animated, Dimensions, @@ -15,7 +14,6 @@ import { StyleSheet, View, } from 'react-native'; -import Events from '@storybook/core-events'; import StoryListView from '../StoryListView'; import StoryView from '../StoryView'; import AbsolutePositionedKeyboardAwareView, { @@ -34,6 +32,8 @@ import { PREVIEW, ADDONS } from './navigation/constants'; import Panel from './Panel'; import { useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { StoryContext } from '@storybook/csf'; +import { ReactNativeFramework } from 'src/types/types-6.0'; const ANIMATION_DURATION = 300; const IS_IOS = Platform.OS === 'ios'; @@ -43,7 +43,8 @@ export const IS_EXPO = getExpoRoot() !== undefined; const IS_ANDROID = Platform.OS === 'android'; const BREAKPOINT = 1024; interface OnDeviceUIProps { - storyStore: StoryStore; + context: StoryContext; + storyIndex: StoryIndex; url?: string; tabOpen?: number; isUIHidden?: boolean; @@ -73,30 +74,9 @@ const styles = StyleSheet.create({ expoAndroidContainer: { paddingTop: StatusBar.currentHeight }, }); -const useSelectedStory = (storyStore: StoryStore) => { - const [storyId, setStoryId] = useState(storyStore.getSelection()?.storyId || ''); - const [, forceUpdate] = useReducer((x) => x + 1, 0); - const channel = useRef(addons.getChannel()); - - useEffect(() => { - const handleStoryWasSet = ({ id: newStoryId }: { id: string }) => setStoryId(newStoryId); - - const currentChannel = channel.current; - channel.current.on(Events.SELECT_STORY, handleStoryWasSet); - //TODO: update preview without force - channel.current.on(Events.FORCE_RE_RENDER, forceUpdate); - - return () => { - currentChannel.removeListener(Events.SELECT_STORY, handleStoryWasSet); - currentChannel.removeListener(Events.FORCE_RE_RENDER, forceUpdate); - }; - }, []); - - return storyStore.fromId(storyId); -}; - const OnDeviceUI = ({ - storyStore, + context, + storyIndex, isUIHidden, shouldDisableKeyboardAvoidingView, keyboardAvoidingViewVerticalOffset, @@ -108,7 +88,6 @@ const OnDeviceUI = ({ width: Dimensions.get('window').width, height: Dimensions.get('window').height, }); - const story = useSelectedStory(storyStore); const animatedValue = useRef(new Animated.Value(tabOpen)); const wide = useWindowDimensions().width >= BREAKPOINT; const insets = useSafeAreaInsets(); @@ -133,14 +112,21 @@ const OnDeviceUI = ({ } }; + const noSafeArea = context?.parameters?.noSafeArea ?? false; const previewWrapperStyles = [ flex, - getPreviewPosition(animatedValue.current, previewDimensions, slideBetweenAnimation, wide), + getPreviewPosition({ + animatedValue: animatedValue.current, + previewDimensions, + slideBetweenAnimation, + wide, + noSafeArea, + insets, + }), ]; const previewStyles = [flex, getPreviewScale(animatedValue.current, slideBetweenAnimation, wide)]; - const noSafeArea = story.parameters?.noSafeArea ?? false; const WrapperView = noSafeArea ? View : SafeAreaView; const wrapperMargin = { marginBottom: isUIVisible ? insets.bottom + 40 : 0 }; return ( @@ -160,7 +146,7 @@ const OnDeviceUI = ({ - + {tabOpen !== PREVIEW ? ( @@ -178,7 +164,7 @@ const OnDeviceUI = ({ wide )} > - + ({ backgroundColor: theme.backgroundColor || 'white', })); diff --git a/app/react-native/src/preview/components/OnDeviceUI/animation.ts b/app/react-native/src/preview/components/OnDeviceUI/animation.ts index c94c0c6eb2..223a7623b7 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/animation.ts +++ b/app/react-native/src/preview/components/OnDeviceUI/animation.ts @@ -1,4 +1,5 @@ import { Animated } from 'react-native'; +import { EdgeInsets } from 'react-native-safe-area-context'; import { PreviewDimens } from './absolute-positioned-keyboard-aware-view'; import { NAVIGATOR, PREVIEW, ADDONS } from './navigation/constants'; @@ -53,15 +54,28 @@ export const getAddonPanelPosition = ( ]; }; -export const getPreviewPosition = ( - animatedValue: Animated.Value, - { width: previewWidth, height: previewHeight }: PreviewDimens, - slideBetweenAnimation: boolean, - wide: boolean -) => { +type PreviewPositionArgs = { + animatedValue: Animated.Value; + previewDimensions: PreviewDimens; + slideBetweenAnimation: boolean; + wide: boolean; + noSafeArea: boolean; + insets: EdgeInsets; +}; + +export const getPreviewPosition = ({ + animatedValue, + previewDimensions: { width: previewWidth, height: previewHeight }, + slideBetweenAnimation, + wide, + noSafeArea, + insets, +}: PreviewPositionArgs) => { const scale = wide ? PREVIEW_WIDE_SCREEN : PREVIEW_SCALE; const translateX = previewWidth / 2 - (previewWidth * scale) / 2 - TRANSLATE_X_OFFSET; - const translateY = -(previewHeight / 2 - (previewHeight * scale) / 2 - TRANSLATE_Y_OFFSET); + const marginTop = noSafeArea ? 0 : insets.top; + const translateY = + -(previewHeight / 2 - (previewHeight * scale) / 2 - TRANSLATE_Y_OFFSET) + marginTop; return { transform: [ @@ -74,7 +88,7 @@ export const getPreviewPosition = ( { translateY: animatedValue.interpolate({ inputRange: [NAVIGATOR, PREVIEW, ADDONS], - outputRange: [translateY, slideBetweenAnimation ? translateY : 0, translateY], + outputRange: [translateY, slideBetweenAnimation ? translateY : marginTop, translateY], }), }, ], diff --git a/app/react-native/src/preview/components/StoryListView/StoryListView.tsx b/app/react-native/src/preview/components/StoryListView/StoryListView.tsx index b243298081..00eb0d9e6a 100644 --- a/app/react-native/src/preview/components/StoryListView/StoryListView.tsx +++ b/app/react-native/src/preview/components/StoryListView/StoryListView.tsx @@ -1,12 +1,14 @@ import styled from '@emotion/native'; import { addons, StoryKind } from '@storybook/addons'; -import { PublishedStoreItem, StoreItem, StoryStore } from '@storybook/client-api'; +import { StoryIndex, StoryIndexEntry } from '@storybook/client-api'; import Events from '@storybook/core-events'; +import { StoryContext } from '@storybook/csf'; import React, { useMemo, useState } from 'react'; import { SectionList, StyleSheet, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { GridIcon, StoryIcon } from '../Shared/icons'; import { Header, Name } from '../Shared/text'; +import { ReactNativeFramework } from 'src/types/types-6.0'; const SearchBar = styled.TextInput( { @@ -55,13 +57,12 @@ interface SectionProps { selected: boolean; } -const SectionHeader = React.memo( - ({ title, selected }: SectionProps) => ( - - -
{title}
-
- )); +const SectionHeader = React.memo(({ title, selected }: SectionProps) => ( + + +
{title}
+
+)); interface ListItemProps { title: string; @@ -98,38 +99,29 @@ const ListItem = React.memo( ); interface Props { - storyStore: StoryStore; - selectedStory: StoreItem; + storyIndex: StoryIndex; + selectedStoryContext: StoryContext; } interface DataItem { title: StoryKind; - data: PublishedStoreItem[]; + data: StoryIndexEntry[]; } -const getStories = (storyStore: StoryStore): DataItem[] => { - if (!storyStore) { +const getStories = (storyIndex: StoryIndex): DataItem[] => { + if (!storyIndex) { return []; } - return Object.values( - storyStore - .raw() - .reduce( - ( - acc: { [kind: string]: { title: string; data: PublishedStoreItem[] } }, - story: PublishedStoreItem - ) => { - acc[story.kind] = { - title: story.kind, - data: (acc[story.kind] ? acc[story.kind].data : []).concat(story), - }; - - return acc; - }, - {} - ) - ); + const groupedStories = Object.values(storyIndex.stories).reduce((acc, story) => { + acc[story.title] = { + title: story.title, + data: (acc[story.title]?.data ?? []).concat(story), + }; + return acc; + }, {} as Record); + + return Object.values(groupedStories); }; const styles = StyleSheet.create({ @@ -138,9 +130,9 @@ const styles = StyleSheet.create({ const tabBarHeight = 40; -const StoryListView = ({ selectedStory, storyStore }: Props) => { +const StoryListView = ({ selectedStoryContext, storyIndex }: Props) => { const insets = useSafeAreaInsets(); - const originalData = useMemo(() => getStories(storyStore), [storyStore]); + const originalData = useMemo(() => getStories(storyIndex), [storyIndex]); const [data, setData] = useState(originalData); const handleChangeSearchText = (text: string) => { @@ -195,13 +187,16 @@ const StoryListView = ({ selectedStory, storyStore }: Props) => { renderItem={({ item }) => ( changeStory(item.id)} /> )} renderSectionHeader={({ section: { title } }) => ( - + )} keyExtractor={(item, index) => item.id + index} sections={data} diff --git a/app/react-native/src/preview/components/StoryView/StoryView.tsx b/app/react-native/src/preview/components/StoryView/StoryView.tsx index 341dd9d018..1477e6c008 100644 --- a/app/react-native/src/preview/components/StoryView/StoryView.tsx +++ b/app/react-native/src/preview/components/StoryView/StoryView.tsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; -import { StoreItem } from '@storybook/client-api'; import { Text, View, StyleSheet } from 'react-native'; -import { StoryContext } from '@storybook/addons'; +import type { StoryContext } from '@storybook/csf'; +import { ReactNativeFramework } from 'src/types/types-6.0'; interface Props { - story?: StoreItem; + context?: StoryContext; } const styles = StyleSheet.create({ @@ -19,26 +19,15 @@ const styles = StyleSheet.create({ }, }); -const StoryView = ({ story }: Props) => { - const [context, setContext] = useState(undefined); - const id = story?.id; - - useEffect(() => { - const loadContext = async () => { - if (story && story.unboundStoryFn && story.applyLoaders) { - setContext(await story.applyLoaders()); - } - }; - loadContext(); - }, [story, id]); - - if (story && story.unboundStoryFn) { - const { unboundStoryFn } = story; - const StoryComponent = (context && context.id === story.id) ? unboundStoryFn : null; +const StoryView = ({ context }: Props) => { + const id = context?.id; + + if (context && context.unboundStoryFn) { + const { unboundStoryFn: StoryComponent } = context; + return ( - {/* We need to get the result by the method of rendering a component, otherwise there will be errors if the react hooks are used */} - { StoryComponent && } + {StoryComponent && } ); } diff --git a/app/react-native/src/preview/executeLoadable.ts b/app/react-native/src/preview/executeLoadable.ts new file mode 100644 index 0000000000..99fe796326 --- /dev/null +++ b/app/react-native/src/preview/executeLoadable.ts @@ -0,0 +1,92 @@ +import { logger } from '@storybook/client-logger'; +import { Path, ModuleExports } from '@storybook/store'; +import { Loadable, RequireContext, LoaderFunction } from '../types/types'; + +declare var global: NodeJS.Global & + typeof globalThis & { lastExportsMap: Map }; +/** + * Executes a Loadable (function that returns exports or require context(s)) + * and returns a map of filename => module exports + * + * @param loadable Loadable + * @returns Map + */ +export function executeLoadable(loadable: Loadable) { + let reqs = null; + // todo discuss / improve type check + if (Array.isArray(loadable)) { + reqs = loadable; + } else if ((loadable as RequireContext).keys) { + reqs = [loadable as RequireContext]; + } + + let exportsMap = new Map(); + if (reqs) { + reqs.forEach((req) => { + req.keys().forEach((filename: string) => { + try { + const fileExports = req(filename) as ModuleExports; + exportsMap.set( + typeof req.resolve === 'function' ? req.resolve(filename) : filename, + fileExports + ); + } catch (error) { + const errorString = + error.message && error.stack ? `${error.message}\n ${error.stack}` : error.toString(); + logger.error(`Unexpected error while loading ${filename}: ${errorString}`); + } + }); + }); + } else { + const exported = (loadable as LoaderFunction)(); + if (Array.isArray(exported) /*FIXME && exported.every((obj) => obj.default != null)*/) { + const csfExports = exported.filter((obj) => obj.default != null); + exportsMap = new Map( + csfExports.map((fileExports, index) => [`exports-map-${index}`, fileExports]) + ); + } + // else if (exported) { + // logger.warn( + // `Loader function passed to 'configure' should return void or an array of module exports that all contain a 'default' export. Received: ${JSON.stringify( + // exported + // )}` + // ); + // } + } + + return exportsMap; +} + +global.lastExportsMap = new Map(); + +/** + * Executes a Loadable (function that returns exports or require context(s)) + * and compares it's output to the last time it was run (as stored on a node module) + * + * @param loadable Loadable + * @param m NodeModule + * @returns { added: Map, removed: Map } + */ +export function executeLoadableForChanges(loadable: Loadable, m?: NodeModule) { + m.hot.accept(); + + const lastExportsMap = global.lastExportsMap as Map; + const exportsMap = executeLoadable(loadable); + const added = new Map(); + Array.from(exportsMap.entries()) + // Ignore files that do not have a default export + .filter(([, fileExports]) => !!fileExports.default) + // Ignore exports that are equal (by reference) to last time, this means the file hasn't changed + .filter(([fileName, fileExports]) => lastExportsMap.get(fileName) !== fileExports) + .forEach(([fileName, fileExports]) => added.set(fileName, fileExports)); + + const removed = new Map(); + Array.from(lastExportsMap.keys()) + .filter((fileName) => !exportsMap.has(fileName)) + .forEach((fileName) => removed.set(fileName, lastExportsMap.get(fileName))); + + // Save the value for the dispose() call above + global.lastExportsMap = exportsMap; + + return { added, removed }; +} diff --git a/app/react-native/src/preview/global.ts b/app/react-native/src/preview/global.ts deleted file mode 100644 index 8da3d30936..0000000000 --- a/app/react-native/src/preview/global.ts +++ /dev/null @@ -1,18 +0,0 @@ -export {}; - -declare global { - // If defining an object you might do something like this - // interface IConfig { a: number, b: number } - - // Extend the Global interface for the NodeJS namespace. - namespace NodeJS { - interface Global { - // Reference our above type, - // this allows global.debug to be used anywhere in our code. - previousExports: Map; - } - } - - // This allows us to simply call debug('some_label')('some debug message') - // from anywhere in our code. -} diff --git a/app/react-native/src/preview/index.ts b/app/react-native/src/preview/index.ts deleted file mode 100644 index fb7f0a8c57..0000000000 --- a/app/react-native/src/preview/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './Preview'; diff --git a/app/react-native/src/preview/loadCsf.ts b/app/react-native/src/preview/loadCsf.ts deleted file mode 100644 index e0b4ce2b8c..0000000000 --- a/app/react-native/src/preview/loadCsf.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { ClientApi, ConfigApi, StoryStore } from '@storybook/client-api'; -import { logger } from '@storybook/client-logger'; -import { isExportStory, storyNameFromExport, toId } from '@storybook/csf'; -import './global'; - -export interface RequireContext { - keys: () => string[]; - (id: string): any; - resolve(id: string): string; -} - -export type LoadableFunction = () => void | any[]; -export type Loadable = RequireContext | RequireContext[] | LoadableFunction; - -const deprecatedStoryAnnotationWarning = () => - logger.log(` - CSF .story annotations deprecated; annotate story functions directly: - - StoryFn.story.name => StoryFn.storyName - - StoryFn.story.(parameters|decorators) => StoryFn.(parameters|decorators) - See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#hoisted-csf-annotations for details and codemod. -`); - -const duplicateKindWarning = (kindName: string) => { - logger.warn(`Duplicate title: '${kindName}' - Duplicate title used in multiple files; use unique titles or a primary file for a component with re-exported stories. - - https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-support-for-duplicate-kinds - `); -}; - -global.previousExports = new Map(); - -const loadStories = ( - loadable: Loadable, - framework: string, - { clientApi, storyStore }: { clientApi: ClientApi; storyStore: StoryStore } -) => () => { - // Make sure we don't try to define a kind more than once within the same load - const loadedKinds = new Set(); - - let reqs = null; - // todo discuss / improve type check - if (Array.isArray(loadable)) { - reqs = loadable; - } else if ((loadable as RequireContext).keys) { - reqs = [loadable as RequireContext]; - } - - let currentExports = new Map(); - - // reqs is not null when require context is used, - // this comes from storybook core client and will never be true in RN - // keeping this here only to get an idea how the core version could be re-used - if (reqs) { - reqs.forEach((req) => { - req.keys().forEach((filename: string) => { - try { - const fileExports = req(filename); - currentExports.set( - fileExports, - // todo discuss: types infer that this is RequireContext; no checks needed? - // NOTE: turns out `babel-plugin-require-context-hook` doesn't implement this (yet) - typeof req.resolve === 'function' ? req.resolve(filename) : filename - ); - } catch (error) { - logger.warn(`Unexpected error while loading ${filename}: ${error}`); - } - }); - }); - } else { - try { - const exported = (loadable as LoadableFunction)(); - if (Array.isArray(exported)) { - const csfExports = exported.filter((obj) => obj.default != null); - currentExports = new Map(csfExports.map((fileExports) => [fileExports, null])); - } else { - logger.warn( - `Loader function passed to 'configure' should return void or an array of module exports that all contain a 'default' export. Received: ${JSON.stringify( - exported - )}` - ); - } - } catch (error) { - logger.warn(`Unexpected error while loading stories: ${error}`); - } - } - const removed = Array.from(global.previousExports.keys()).filter( - (exp) => !currentExports.has(exp) - ); - removed.forEach((exp: any) => { - if (exp.default) { - storyStore.removeStoryKind(exp.default.title); - } - }); - - const added = Array.from(currentExports.keys()).filter((exp) => !global.previousExports.has(exp)); - - added.forEach((fileExports) => { - // An old-style story file - if (!fileExports.default) { - return; - } - - if (!fileExports.default.title) { - throw new Error( - `Unexpected default export without title: ${JSON.stringify(fileExports.default)}` - ); - } - - const { default: meta, __namedExportsOrder, ...namedExports } = fileExports; - let exports = namedExports; - - // prefer a user/loader provided `__namedExportsOrder` array if supplied - // we do this as es module exports are always ordered alphabetically - // see https://github.com/storybookjs/storybook/issues/9136 - if (Array.isArray(__namedExportsOrder)) { - exports = {}; - __namedExportsOrder.forEach((name) => { - if (namedExports[name]) { - exports[name] = namedExports[name]; - } - }); - } - - const { - title: kindName, - id: componentId, - parameters: kindParameters, - decorators: kindDecorators, - loaders: kindLoaders = [], - component, - subcomponents, - args: kindArgs, - argTypes: kindArgTypes, - } = meta; - - if (loadedKinds.has(kindName)) { - duplicateKindWarning(kindName); - } - loadedKinds.add(kindName); - - // We pass true here to avoid the warning about HMR. It's cool clientApi, we got this - // todo discuss: TS now wants a NodeModule; should we fix this differently? - const kind = clientApi.storiesOf(kindName, true as any); - - // we should always have a framework, rest optional - kind.addParameters({ - framework, - component, - subcomponents, - fileName: currentExports.get(fileExports), - ...kindParameters, - args: kindArgs, - argTypes: kindArgTypes, - }); - - // todo add type - (kindDecorators || []).forEach((decorator: any) => { - kind.addDecorator(decorator); - }); - - kindLoaders.forEach((loader: any) => { - kind.addLoader(loader); - }); - - const storyExports = Object.keys(exports); - if (storyExports.length === 0) { - logger.warn( - ` - Found a story file for "${kindName}" but no exported stories. - Check the docs for reference: https://storybook.js.org/docs/formats/component-story-format/ - ` - ); - return; - } - - storyExports.forEach((key) => { - if (isExportStory(key, meta)) { - const storyFn = exports[key]; - const { story } = storyFn; - const name = story?.name; - const { storyName = name } = storyFn; - - // storyFn.x and storyFn.story.x get merged with - // storyFn.x taking precedence in the merge - const parameters = { ...story?.parameters, ...storyFn.parameters }; - const decorators = [...(storyFn.decorators || []), ...(story?.decorators || [])]; - const loaders = [...(storyFn.loaders || []), ...(story?.loaders || [])]; - const args = { ...story?.args, ...storyFn.args }; - const argTypes = { ...story?.argTypes, ...storyFn.argTypes }; - - if (story) { - logger.debug('deprecated story', story); - deprecatedStoryAnnotationWarning(); - } - - const exportName = storyNameFromExport(key); - const storyParams = { - ...parameters, - __id: toId(componentId || kindName, exportName), - decorators, - loaders, - args, - argTypes, - }; - kind.add(storyName || exportName, storyFn, storyParams); - } - }); - }); - - global.previousExports = currentExports; -}; - -const configureDeprecationWarning = () => - logger.log( - `\`configure()\` is deprecated and will be removed in Storybook 7.0. -Please use the \`stories\` field of \`main.js\` to load stories. -Read more at https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-configure` - ); - -export const loadCsf = ({ - clientApi, - storyStore, - configApi, -}: { - clientApi: ClientApi; - storyStore: StoryStore; - configApi: ConfigApi; -}) => - /** - * Load a collection of stories. If it has a default export, assume that it is a module-style - * file and process its named exports as stories. If not, assume it's an old-style - * storiesof file and require it. - * - * @param {*} framework - name of framework in use, e.g. "react" - * @param {*} loadable a require.context `req`, an array of `req`s, or a loader function that returns void or an array of exports - * @param {*} m - ES module object for hot-module-reloading (HMR) - * @param {boolean} showDeprecationWarning - show the deprecation warning (default true) - */ - (framework: string, loadable: Loadable, m: NodeModule, showDeprecationWarning = true) => { - if (showDeprecationWarning) { - configureDeprecationWarning(); - } - - if (typeof m === 'string') { - throw new Error( - `Invalid module '${m}'. Did you forget to pass \`module\` as the second argument to \`configure\`"?` - ); - } - - configApi.configure(loadStories(loadable, framework, { clientApi, storyStore }), m); - }; diff --git a/app/react-native/src/preview/start.tsx b/app/react-native/src/preview/start.tsx new file mode 100644 index 0000000000..f905f7fcaf --- /dev/null +++ b/app/react-native/src/preview/start.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import Channel from '@storybook/channels'; +import { addons } from '@storybook/addons'; +import Events from '@storybook/core-events'; +import { Loadable } from '@storybook/core-client'; +import { PreviewWeb } from '@storybook/preview-web'; +import { ClientApi, RenderContext, setGlobalRender } from '@storybook/client-api'; +import type { ReactNativeFramework } from '../types/types-6.0'; +import { View } from './View'; +import { executeLoadableForChanges } from './executeLoadable'; +import type { ArgsStoryFn } from '@storybook/csf'; + +export const render: ArgsStoryFn = (args, context) => { + const { id, component: Component } = context; + if (!Component) { + throw new Error( + `Unable to render story ${id} as the component annotation is missing from the default export` + ); + } + + return ; +}; + +export function start() { + const channel = new Channel({ async: true }); + addons.setChannel(channel); + + const clientApi = new ClientApi(); + + const preview = new PreviewWeb(); + + clientApi.storyStore = preview.storyStore; + setGlobalRender(render); + + preview.urlStore = { + selection: { storyId: '', viewMode: 'story' }, + selectionSpecifier: null, + setQueryParams: () => {}, + setSelection: (selection) => { + preview.urlStore.selection = selection; + }, + }; + + preview.view = { + ...preview.view, + prepareForStory: () => null, + showNoPreview: () => {}, + showPreparingStory: () => {}, + applyLayout: () => {}, + showErrorDisplay: (e) => { + console.log(e); + }, + showStoryDuringRender: () => {}, + showMain: () => {}, + // these are just to make typescript happy + showDocs: preview.view?.showDocs, + storyRoot: preview.view?.storyRoot, + prepareForDocs: preview.view?.prepareForDocs, + docsRoot: preview.view?.docsRoot, + checkIfLayoutExists: preview.view?.checkIfLayoutExists, + showMode: preview.view?.showMode, + showPreparingDocs: preview.view?.showPreparingDocs, + showStory: preview.view?.showStory, + }; + + let initialized = false; + + function onStoriesChanged() { + const storyIndex = clientApi.getStoryIndex(); + preview.onStoriesChanged({ storyIndex }); + view._storyIndex = storyIndex; + } + + const view = new View(preview); + + return { + view, + forceReRender: () => channel.emit(Events.FORCE_RE_RENDER), + clientApi, + preview, + // This gets called each time the user calls configure (i.e. once per HMR) + // The first time, it constructs the preview, subsequently it updates it + configure(loadable: Loadable, m: NodeModule) { + clientApi.addParameters({ framework: 'react-native' }); + + // We need to run the `executeLoadableForChanges` function *inside* the `getProjectAnnotations + // function in case it throws. So we also need to process its output there also + const getProjectAnnotations = () => { + const { added, removed } = executeLoadableForChanges(loadable, m); + + Array.from(added.entries()).forEach(([fileName, fileExports]) => + clientApi.facade.addStoriesFromExports(fileName, fileExports) + ); + + Array.from(removed.entries()).forEach(([fileName]) => + clientApi.facade.clearFilenameExports(fileName) + ); + + return { + ...clientApi.facade.projectAnnotations, + renderToDOM: (context: RenderContext) => { + view._setStory(context.storyContext); + }, + }; + }; + + const importFn = (path: string) => clientApi.importFn(path); + + if (!initialized) { + preview.initialize({ + getStoryIndex: () => { + const index = clientApi.getStoryIndex(); + view._storyIndex = index; + return index; + }, + importFn, + getProjectAnnotations, + }); + initialized = true; + } else { + // TODO -- why don't we care about the new annotations? + getProjectAnnotations(); + onStoriesChanged(); + } + }, + }; +} diff --git a/app/react-native/src/types-6.0.ts b/app/react-native/src/types-6.0.ts deleted file mode 100644 index f4167b801c..0000000000 --- a/app/react-native/src/types-6.0.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Annotations, Args as DefaultArgs, BaseMeta, BaseStory } from '@storybook/addons'; -import { ComponentProps, ComponentType, JSXElementConstructor, ReactElement } from 'react'; - -export type StoryFnReactReturnType = ReactElement; - -export type { Args, ArgTypes, Parameters, StoryContext } from '@storybook/addons'; - -type ReactComponent = ComponentType; -type ReactReturnType = StoryFnReactReturnType; - -/** - * Metadata to configure the stories for a component. - * - * @see [Default export](https://storybook.js.org/docs/formats/component-story-format/#default-export) - */ -export type Meta = BaseMeta & - Annotations; - -/** - * Story function that represents a component example. - * - * @see [Named Story exports](https://storybook.js.org/docs/formats/component-story-format/#named-story-exports) - */ -export type Story = BaseStory & - Annotations; - -/** - * For the common case where a component's stories are simple components that receives args as props: - * - * ```tsx - * export default { ... } as ComponentMeta; - * ``` - */ -export type ComponentMeta< - T extends keyof JSX.IntrinsicElements | JSXElementConstructor -> = Meta>; - -/** - * For the common case where a story is a simple component that receives args as props: - * - * ```tsx - * const Template: ComponentStory = (args) =>