Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-devtools-private-api.md
Original file line number Diff line number Diff line change
@@ -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)).
16 changes: 10 additions & 6 deletions packages/web/src/react-native/devtools.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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__: {
Expand Down Expand Up @@ -49,7 +53,7 @@ const bindingName = fuseboxDispatcher.BINDING_NAME;

const { connect, disconnectIfNeeded } = createFuseboxConnection({
sessionStore,
ReactNativeStyleAttributes,
nativeStyleEditorValidAttributes,
resolveRNStyle,
connectWithCustomMessagingProtocol,
savePersistedHookSettings,
Expand Down
34 changes: 34 additions & 0 deletions packages/web/src/react-native/flattenStyle.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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;
6 changes: 3 additions & 3 deletions packages/web/src/react-native/fuseboxConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { FuseboxDomain } from './types.js';

export type FuseboxConnectionDeps = {
sessionStore: SessionStore;
ReactNativeStyleAttributes: Record<string, unknown>;
nativeStyleEditorValidAttributes: string[];
resolveRNStyle: (style: unknown) => unknown;
connectWithCustomMessagingProtocol: (options: {
onSubscribe: (listener: (event: unknown) => void) => void;
Expand All @@ -28,7 +28,7 @@ export type FuseboxConnectionDeps = {
export const createFuseboxConnection = (deps: FuseboxConnectionDeps) => {
const {
sessionStore,
ReactNativeStyleAttributes,
nativeStyleEditorValidAttributes,
resolveRNStyle,
connectWithCustomMessagingProtocol,
savePersistedHookSettings,
Expand Down Expand Up @@ -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,
Expand Down
118 changes: 118 additions & 0 deletions packages/web/src/react-native/fuseboxReactDevToolsDispatcher.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<JSONValue>;

constructor(name: DomainName) {
if (
(global as Record<string, unknown>)[FuseboxReactDevToolsDispatcher.BINDING_NAME] == null
) {
throw new Error(`Could not create domain ${name}: receiving end doesn't exist`);
}

this.name = name;
this.onMessage = new EventScope<JSONValue>();
}

sendMessage(message: { event: unknown; payload: unknown }) {
const messageWithDomain = { domain: this.name, message };
const serializedMessageWithDomain = JSON.stringify(messageWithDomain);

(
(global as Record<string, unknown>)[
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<DomainName, Domain>();

// Referenced and initialized from Chrome DevTools frontend.
static BINDING_NAME = '__CHROME_DEVTOOLS_FRONTEND_BINDING__';
static onDomainInitialization = new EventScope<Domain>();

// 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<string, unknown>).__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__ == null) {
Object.defineProperty(global, '__FUSEBOX_REACT_DEVTOOLS_DISPATCHER__', {
value: FuseboxReactDevToolsDispatcher,
configurable: false,
enumerable: false,
writable: false,
});
}
Loading
Loading