diff --git a/.changeset/web-devtools-private-api.md b/.changeset/web-devtools-private-api.md new file mode 100644 index 00000000..32341561 --- /dev/null +++ b/.changeset/web-devtools-private-api.md @@ -0,0 +1,5 @@ +--- +'@rozenite/web': patch +--- + +Replace private `react-native/src/private/...` and `react-native/Libraries/...` imports in the React DevTools Fusebox bootstrap with local, vendored equivalents, so the package no longer depends on React Native's internal module paths (compatible with [Strict TypeScript API](https://reactnative.dev/docs/strict-typescript-api)). diff --git a/packages/web/src/react-native/devtools.ts b/packages/web/src/react-native/devtools.ts index 4d96b0a4..8e9da481 100644 --- a/packages/web/src/react-native/devtools.ts +++ b/packages/web/src/react-native/devtools.ts @@ -1,9 +1,14 @@ /** * This is a modified version of the setUpReactDevTools.js from react-native. - * @see https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/setUpReactDevTools.js + * @see https://github.com/facebook/react-native/blob/v0.86.0/packages/react-native/Libraries/Core/setUpReactDevTools.js * * Important: do NOT import from 'react-native' directly! * For some reason, it'll break the hook and React DevTools won't work. + * + * The Fusebox dispatcher setup, style-attribute list, and style flattener + * below are vendored from react-native's private modules (see the @see + * links on each) rather than imported, since those paths are blocked under + * react-native's Strict TypeScript API. */ import type { FuseboxDomain } from './types.js'; @@ -13,10 +18,9 @@ import { getReloadAndProfileConfig, setReloadAndProfileConfig } from './storage/ import { readReloadAndProfileConfig } from './reloadAndProfile.js'; import { createFuseboxConnection } from './fuseboxConnection.js'; import { initialize, connectWithCustomMessagingProtocol } from 'react-devtools-core'; -// Use subpath imports - do NOT import from 'react-native' directly (breaks React DevTools) -import 'react-native/src/private/devsupport/rndevtools/setUpFuseboxReactDevToolsDispatcher'; -import ReactNativeStyleAttributes from 'react-native/Libraries/Components/View/ReactNativeStyleAttributes'; -import resolveRNStyle from 'react-native/Libraries/StyleSheet/flattenStyle'; +import './fuseboxReactDevToolsDispatcher.js'; +import { nativeStyleEditorValidAttributes } from './nativeStyleEditorValidAttributes.js'; +import resolveRNStyle from './flattenStyle.js'; declare global { var __FUSEBOX_REACT_DEVTOOLS_DISPATCHER__: { @@ -49,7 +53,7 @@ const bindingName = fuseboxDispatcher.BINDING_NAME; const { connect, disconnectIfNeeded } = createFuseboxConnection({ sessionStore, - ReactNativeStyleAttributes, + nativeStyleEditorValidAttributes, resolveRNStyle, connectWithCustomMessagingProtocol, savePersistedHookSettings, diff --git a/packages/web/src/react-native/flattenStyle.ts b/packages/web/src/react-native/flattenStyle.ts new file mode 100644 index 00000000..c7995abc --- /dev/null +++ b/packages/web/src/react-native/flattenStyle.ts @@ -0,0 +1,34 @@ +/** + * Vendored from React Native's `flattenStyle.js`, which lives under the + * `Libraries/*` path blocked by React Native's Strict TypeScript API + * (https://reactnative.dev/docs/strict-typescript-api). The function has no + * dependency on any other RN-internal module, so it's copied here verbatim + * aside from the Flow -> TypeScript conversion. + * + * @see https://github.com/facebook/react-native/blob/v0.86.0/packages/react-native/Libraries/StyleSheet/flattenStyle.js + */ + +type Style = Record; + +const flattenStyle = (style: unknown): Style | undefined => { + if (style === null || style === undefined || typeof style !== 'object') { + return undefined; + } + + if (!Array.isArray(style)) { + return style as Style; + } + + const result: Style = {}; + for (let i = 0, styleLength = style.length; i < styleLength; ++i) { + const computedStyle = flattenStyle(style[i]); + if (computedStyle) { + for (const key in computedStyle) { + result[key] = computedStyle[key]; + } + } + } + return result; +}; + +export default flattenStyle; diff --git a/packages/web/src/react-native/fuseboxConnection.ts b/packages/web/src/react-native/fuseboxConnection.ts index 548b1d23..0f86bb47 100644 --- a/packages/web/src/react-native/fuseboxConnection.ts +++ b/packages/web/src/react-native/fuseboxConnection.ts @@ -7,7 +7,7 @@ import type { FuseboxDomain } from './types.js'; export type FuseboxConnectionDeps = { sessionStore: SessionStore; - ReactNativeStyleAttributes: Record; + nativeStyleEditorValidAttributes: string[]; resolveRNStyle: (style: unknown) => unknown; connectWithCustomMessagingProtocol: (options: { onSubscribe: (listener: (event: unknown) => void) => void; @@ -28,7 +28,7 @@ export type FuseboxConnectionDeps = { export const createFuseboxConnection = (deps: FuseboxConnectionDeps) => { const { sessionStore, - ReactNativeStyleAttributes, + nativeStyleEditorValidAttributes, resolveRNStyle, connectWithCustomMessagingProtocol, savePersistedHookSettings, @@ -71,7 +71,7 @@ export const createFuseboxConnection = (deps: FuseboxConnectionDeps) => { onMessage: (event, payload) => { domain.sendMessage({ event, payload }); }, - nativeStyleEditorValidAttributes: Object.keys(ReactNativeStyleAttributes), + nativeStyleEditorValidAttributes, resolveRNStyle, onSettingsUpdated: handleSettingsUpdate, isReloadAndProfileSupported, diff --git a/packages/web/src/react-native/fuseboxReactDevToolsDispatcher.ts b/packages/web/src/react-native/fuseboxReactDevToolsDispatcher.ts new file mode 100644 index 00000000..64efecf4 --- /dev/null +++ b/packages/web/src/react-native/fuseboxReactDevToolsDispatcher.ts @@ -0,0 +1,118 @@ +/** + * Vendored from React Native's `setUpFuseboxReactDevToolsDispatcher.js`, which + * is only reachable via the private `react-native/src/private/...` path and + * is blocked entirely under React Native's Strict TypeScript API + * (https://reactnative.dev/docs/strict-typescript-api). The module is fully + * self-contained (no further RN-internal imports), so it's copied here + * verbatim aside from the Flow -> TypeScript conversion and the idempotency + * guard noted below. + * + * @see https://github.com/facebook/react-native/blob/v0.86.0/packages/react-native/src/private/devsupport/rndevtools/setUpFuseboxReactDevToolsDispatcher.js + */ + +import type { FuseboxDomain } from './types.js'; + +type JSONValue = + | string + | number + | boolean + | null + | { [key: string]: JSONValue } + | JSONValue[]; +type DomainName = 'react-devtools'; + +class EventScope { + private listeners = new Set<(value: T) => void>(); + + addEventListener(listener: (value: T) => void): void { + this.listeners.add(listener); + } + + removeEventListener(listener: (value: T) => void): void { + this.listeners.delete(listener); + } + + emit(value: T): void { + // Assuming that listeners won't throw. + for (const listener of this.listeners) { + listener(value); + } + } +} + +class Domain implements FuseboxDomain { + name: DomainName; + onMessage: EventScope; + + constructor(name: DomainName) { + if ( + (global as Record)[FuseboxReactDevToolsDispatcher.BINDING_NAME] == null + ) { + throw new Error(`Could not create domain ${name}: receiving end doesn't exist`); + } + + this.name = name; + this.onMessage = new EventScope(); + } + + sendMessage(message: { event: unknown; payload: unknown }) { + const messageWithDomain = { domain: this.name, message }; + const serializedMessageWithDomain = JSON.stringify(messageWithDomain); + + ( + (global as Record)[ + FuseboxReactDevToolsDispatcher.BINDING_NAME + ] as (message: string) => void + )(serializedMessageWithDomain); + } +} + +/** + * Globally bound object providing a hook for React DevTools runtime API calls + * over CDP. + */ +class FuseboxReactDevToolsDispatcher { + static domainNameToDomainMap = new Map(); + + // Referenced and initialized from Chrome DevTools frontend. + static BINDING_NAME = '__CHROME_DEVTOOLS_FRONTEND_BINDING__'; + static onDomainInitialization = new EventScope(); + + // Should be private, referenced from Chrome DevTools frontend only. + static initializeDomain(domainName: DomainName): Domain { + const domain = new Domain(domainName); + + this.domainNameToDomainMap.set(domainName, domain); + this.onDomainInitialization.emit(domain); + + return domain; + } + + // Should be private, referenced from Chrome DevTools frontend only. + static sendMessage(domainName: DomainName, message: string): void { + const domain = this.domainNameToDomainMap.get(domainName); + if (domain == null) { + throw new Error(`Could not send message to ${domainName}: domain doesn't exist`); + } + + try { + const parsedMessage = JSON.parse(message); + domain.onMessage.emit(parsedMessage); + } catch (err) { + console.error(`Error while trying to send a message to domain ${domainName}:`, err); + } + } +} + +// Unlike RN's original module-level side effect, guard against redefining +// the global if something else already set it up first, since +// Object.defineProperty below would otherwise throw on a non-configurable +// property. +if ((global as Record).__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ == null) { + Object.defineProperty(global, '__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__', { + value: FuseboxReactDevToolsDispatcher, + configurable: false, + enumerable: false, + writable: false, + }); +} diff --git a/packages/web/src/react-native/nativeStyleEditorValidAttributes.ts b/packages/web/src/react-native/nativeStyleEditorValidAttributes.ts new file mode 100644 index 00000000..9b9b2079 --- /dev/null +++ b/packages/web/src/react-native/nativeStyleEditorValidAttributes.ts @@ -0,0 +1,193 @@ +/** + * React DevTools' style editor only needs the *names* of valid native style + * attributes (`Object.keys(ReactNativeStyleAttributes)` in + * `fuseboxConnection.ts`) — it never reads the `process`/`diff` value + * objects RN attaches to each key. Those value objects are what pull in + * RN's private `Libraries/StyleSheet/process*` modules and the private + * `ReactNativeFeatureFlags` module, so instead of vendoring the whole + * attribute table we vendor just the key list, matching the same set of + * attribute names as RN's `ReactNativeStyleAttributes.js` (grouped the same + * way, to make re-syncing on a React Native upgrade easier to diff). + * + * @see https://github.com/facebook/react-native/blob/v0.86.0/packages/react-native/Libraries/Components/View/ReactNativeStyleAttributes.js + */ + +export const nativeStyleEditorValidAttributes: string[] = [ + // Layout + 'alignContent', + 'alignItems', + 'alignSelf', + 'aspectRatio', + 'borderBottomWidth', + 'borderEndWidth', + 'borderLeftWidth', + 'borderRightWidth', + 'borderStartWidth', + 'borderTopWidth', + 'boxSizing', + 'columnGap', + 'borderWidth', + 'bottom', + 'direction', + 'display', + 'end', + 'flex', + 'flexBasis', + 'flexDirection', + 'flexGrow', + 'flexShrink', + 'flexWrap', + 'gap', + 'height', + 'inset', + 'insetBlock', + 'insetBlockEnd', + 'insetBlockStart', + 'insetInline', + 'insetInlineEnd', + 'insetInlineStart', + 'justifyContent', + 'left', + 'margin', + 'marginBlock', + 'marginBlockEnd', + 'marginBlockStart', + 'marginBottom', + 'marginEnd', + 'marginHorizontal', + 'marginInline', + 'marginInlineEnd', + 'marginInlineStart', + 'marginLeft', + 'marginRight', + 'marginStart', + 'marginTop', + 'marginVertical', + 'maxHeight', + 'maxWidth', + 'minHeight', + 'minWidth', + 'overflow', + 'padding', + 'paddingBlock', + 'paddingBlockEnd', + 'paddingBlockStart', + 'paddingBottom', + 'paddingEnd', + 'paddingHorizontal', + 'paddingInline', + 'paddingInlineEnd', + 'paddingInlineStart', + 'paddingLeft', + 'paddingRight', + 'paddingStart', + 'paddingTop', + 'paddingVertical', + 'position', + 'right', + 'rowGap', + 'start', + 'top', + 'width', + 'zIndex', + + // Shadow + 'elevation', + 'shadowColor', + 'shadowOffset', + 'shadowOpacity', + 'shadowRadius', + + // Transform + 'transform', + 'transformOrigin', + + // Filter + 'filter', + + // MixBlendMode + 'mixBlendMode', + + // Isolation + 'isolation', + + // BoxShadow + 'boxShadow', + + // BackgroundImage + 'experimental_backgroundImage', + + // BackgroundSize + 'experimental_backgroundSize', + + // BackgroundPosition + 'experimental_backgroundPosition', + + // BackgroundRepeat + 'experimental_backgroundRepeat', + + // View + 'backfaceVisibility', + 'backgroundColor', + 'borderBlockColor', + 'borderBlockEndColor', + 'borderBlockStartColor', + 'borderBottomColor', + 'borderBottomEndRadius', + 'borderBottomLeftRadius', + 'borderBottomRightRadius', + 'borderBottomStartRadius', + 'borderColor', + 'borderCurve', + 'borderEndColor', + 'borderEndEndRadius', + 'borderEndStartRadius', + 'borderLeftColor', + 'borderRadius', + 'borderRightColor', + 'borderStartColor', + 'borderStartEndRadius', + 'borderStartStartRadius', + 'borderStyle', + 'borderTopColor', + 'borderTopEndRadius', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderTopStartRadius', + 'cursor', + 'opacity', + 'outlineColor', + 'outlineOffset', + 'outlineStyle', + 'outlineWidth', + 'pointerEvents', + + // Text + 'color', + 'fontFamily', + 'fontSize', + 'fontStyle', + 'fontVariant', + 'fontWeight', + 'includeFontPadding', + 'letterSpacing', + 'lineHeight', + 'textAlign', + 'textAlignVertical', + 'textDecorationColor', + 'textDecorationLine', + 'textDecorationStyle', + 'textShadowColor', + 'textShadowOffset', + 'textShadowRadius', + 'textTransform', + 'userSelect', + 'verticalAlign', + 'writingDirection', + + // Image + 'overlayColor', + 'resizeMode', + 'tintColor', + 'objectFit', +];