From 1e241f9d6c5f7d0e875b19a99c83cd6197fa62f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 28 Jun 2024 15:25:10 +0200 Subject: [PATCH] Add renderToMarkup for Client Components (#30121) Follow up to #30105. This supports `renderToMarkup` in a non-RSC environment (not the `react-server` condition). This is just a Fizz renderer but it errors at runtime when you use state, effects or event handlers that would require hydration - like the RSC version would. (Except RSC can give early errors too.) To do this I have to move the `react-html` builds to a new `markup` dimension out of the `dom-legacy` dimension so that we can configure this differently from `renderToString`/`renderToStaticMarkup`. Eventually that dimension can go away though if deprecated. That also helps us avoid dynamic configuration and we can just compile in the right configuration so the split helps anyway. One consideration is that if a compiler strips out useEffects or inlines initial state from useState, then it would not get called an the error wouldn't happen. Therefore to preserve semantics, a compiler would need to inject some call that can check the current renderer and whether it should throw. There is an argument that it could be useful to not error for these because it's possible to write components that works with SSR but are just optionally hydrated. However, there's also an argument that doing that silently is too easy to lead to mistakes and it's better to error - especially for the e-mail use case where you can't take it back but you can replay a queue that had failures. There are other ways to conditionally branch components intentionally. Besides if you want it to be silent you can still use renderToString (or better yet renderToReadableStream). The primary mechanism is the RSC environment and the client-environment is really the secondary one that's only there to support legacy environments. So this also ensures parity with the primary environment. --- .../ReactFlightClientConfig.dom-legacy.js | 90 ++------- .../forks/ReactFlightClientConfig.markup.js | 88 ++++++++ .../src/server/ReactFizzConfigDOM.js | 2 + .../src/server/ReactFizzConfigDOMLegacy.js | 1 + packages/react-html/index.js | 13 +- packages/react-html/npm/index.js | 8 +- .../react-html/src/ReactFizzConfigHTML.js | 188 ++++++++++++++++++ packages/react-html/src/ReactHTMLClient.js | 100 ++++++++++ packages/react-html/src/ReactHTMLServer.js | 11 +- .../src/__tests__/ReactHTMLClient-test.js | 141 +++++++++++++ .../src/__tests__/ReactHTMLServer-test.js | 38 ++++ .../src/forks/ReactFiberConfig.markup.js | 16 ++ packages/react-server/src/ReactFizzHooks.js | 79 +++++--- .../src/forks/ReactFizzConfig.custom.js | 2 + .../src/forks/ReactFizzConfig.markup.js | 16 ++ .../ReactFlightServerConfig.dom-legacy.js | 77 +------ .../forks/ReactFlightServerConfig.markup.js | 90 +++++++++ .../forks/ReactServerStreamConfig.markup.js | 10 + scripts/error-codes/codes.json | 5 +- scripts/rollup/bundles.js | 14 +- scripts/rollup/forks.js | 1 + scripts/shared/inlinedHostConfigs.js | 17 +- 22 files changed, 826 insertions(+), 181 deletions(-) create mode 100644 packages/react-client/src/forks/ReactFlightClientConfig.markup.js create mode 100644 packages/react-html/src/ReactFizzConfigHTML.js create mode 100644 packages/react-html/src/ReactHTMLClient.js create mode 100644 packages/react-html/src/__tests__/ReactHTMLClient-test.js create mode 100644 packages/react-reconciler/src/forks/ReactFiberConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactFizzConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactFlightServerConfig.markup.js create mode 100644 packages/react-server/src/forks/ReactServerStreamConfig.markup.js diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 8e91e3a8062fd..b992b01803260 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -7,82 +7,20 @@ * @flow */ -import type {Thenable} from 'shared/ReactTypes'; +export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; -export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; -export * from 'react-client/src/ReactClientConsoleConfigPlain'; - -export type ModuleLoading = null; -export type SSRModuleMap = null; -export opaque type ServerManifest = null; +export type Response = any; +export opaque type ModuleLoading = mixed; +export opaque type SSRModuleMap = mixed; +export opaque type ServerManifest = mixed; export opaque type ServerReferenceId = string; -export opaque type ClientReferenceMetadata = null; -export opaque type ClientReference = null; // eslint-disable-line no-unused-vars - -export function prepareDestinationForModule( - moduleLoading: ModuleLoading, - nonce: ?string, - metadata: ClientReferenceMetadata, -) { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - -export function resolveClientReference( - bundlerConfig: SSRModuleMap, - metadata: ClientReferenceMetadata, -): ClientReference { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - -export function resolveServerReference( - config: ServerManifest, - id: ServerReferenceId, -): ClientReference { - throw new Error( - 'renderToMarkup should not have emitted Server References. This is a bug in React.', - ); -} - -export function preloadModule( - metadata: ClientReference, -): null | Thenable { - return null; -} - -export function requireModule(metadata: ClientReference): T { - throw new Error( - 'renderToMarkup should not have emitted Client References. This is a bug in React.', - ); -} - +export opaque type ClientReferenceMetadata = mixed; +export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars +export const resolveClientReference: any = null; +export const resolveServerReference: any = null; +export const preloadModule: any = null; +export const requireModule: any = null; +export const dispatchHint: any = null; +export const prepareDestinationForModule: any = null; export const usedWithSSR = true; - -type HintCode = string; -type HintModel = null; // eslint-disable-line no-unused-vars - -export function dispatchHint( - code: Code, - model: HintModel, -): void { - // Should never happen. -} - -export function preinitModuleForSSR( - href: string, - nonce: ?string, - crossOrigin: ?string, -) { - // Should never happen. -} - -export function preinitScriptForSSR( - href: string, - nonce: ?string, - crossOrigin: ?string, -) { - // Should never happen. -} diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js new file mode 100644 index 0000000000000..8e91e3a8062fd --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export type ModuleLoading = null; +export type SSRModuleMap = null; +export opaque type ServerManifest = null; +export opaque type ServerReferenceId = string; +export opaque type ClientReferenceMetadata = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveClientReference( + bundlerConfig: SSRModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + return null; +} + +export function requireModule(metadata: ClientReference): T { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export const usedWithSSR = true; + +type HintCode = string; +type HintModel = null; // eslint-disable-line no-unused-vars + +export function dispatchHint( + code: Code, + model: HintModel, +): void { + // Should never happen. +} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 731b6f2483d07..594051dc35c2c 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -108,6 +108,8 @@ export type HeadersDescriptor = { // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; +export const supportsClientAPIs = true; + export type StreamingFormat = 0 | 1; const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 92cd890976d22..725ee666f52af 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -166,6 +166,7 @@ export { resetResumableState, completeResumableState, emitEarlyPreloads, + supportsClientAPIs, } from './ReactFizzConfigDOM'; import escapeTextForBrowser from './escapeTextForBrowser'; diff --git a/packages/react-html/index.js b/packages/react-html/index.js index a1818e8c3bba6..01e5f5e51b08c 100644 --- a/packages/react-html/index.js +++ b/packages/react-html/index.js @@ -1,5 +1,10 @@ -'use strict'; +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ -throw new Error( - 'react-html is not supported outside a React Server Components environment.', -); +export * from './src/ReactHTMLClient'; diff --git a/packages/react-html/npm/index.js b/packages/react-html/npm/index.js index e567bb2c0aa21..753fdef93e42d 100644 --- a/packages/react-html/npm/index.js +++ b/packages/react-html/npm/index.js @@ -1,5 +1,7 @@ 'use strict'; -throw new Error( - 'react-html is not supported outside a React Server Components environment.' -); +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.production.js'); +} else { + module.exports = require('./cjs/react-html.development.js'); +} diff --git a/packages/react-html/src/ReactFizzConfigHTML.js b/packages/react-html/src/ReactFizzConfigHTML.js new file mode 100644 index 0000000000000..81528b0cdb81c --- /dev/null +++ b/packages/react-html/src/ReactFizzConfigHTML.js @@ -0,0 +1,188 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import type { + Destination, + Chunk, + PrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import hasOwnProperty from 'shared/hasOwnProperty'; + +// Allow embedding inside another Fizz render. +export const isPrimaryRenderer = false; + +// Disable Client Hooks +export const supportsClientAPIs = false; + +import { + stringToChunk, + stringToPrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +// this chunk is empty on purpose because we do not want to emit the DOCTYPE +// when markup is rendering HTML +export const doctypeChunk: PrecomputedChunk = stringToPrecomputedChunk(''); + +export type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export { + getChildFormatContext, + makeId, + pushEndInstance, + pushStartCompletedSuspenseBoundary, + pushEndCompletedSuspenseBoundary, + pushFormStateMarkerIsMatching, + pushFormStateMarkerIsNotMatching, + writeStartSegment, + writeEndSegment, + writeCompletedSegmentInstruction, + writeCompletedBoundaryInstruction, + writeClientRenderBoundaryInstruction, + writeStartPendingSuspenseBoundary, + writeEndPendingSuspenseBoundary, + writeHoistablesForBoundary, + writePlaceholder, + writeCompletedRoot, + createRootFormatContext, + createRenderState, + createResumableState, + createHoistableState, + writePreamble, + writeHoistables, + writePostamble, + hoistHoistables, + resetResumableState, + completeResumableState, + emitEarlyPreloads, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; + +export function pushStartInstance( + target: Array, + type: string, + props: Object, + resumableState: ResumableState, + renderState: RenderState, + hoistableState: null | HoistableState, + formatContext: FormatContext, + textEmbedded: boolean, + isFallback: boolean, +): ReactNodeList { + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propKey === 'ref' && propValue != null) { + throw new Error( + 'Cannot pass ref in renderToMarkup because they will never be hydrated.', + ); + } + if (typeof propValue === 'function') { + throw new Error( + 'Cannot pass event handlers (' + + propKey + + ') in renderToMarkup because ' + + 'the HTML will never be hydrated so they can never get called.', + ); + } + } + } + + return pushStartInstanceImpl( + target, + type, + props, + resumableState, + renderState, + hoistableState, + formatContext, + textEmbedded, + isFallback, + ); +} + +export function pushTextInstance( + target: Array, + text: string, + renderState: RenderState, + textEmbedded: boolean, +): boolean { + // Markup doesn't need any termination. + target.push(stringToChunk(escapeTextForBrowser(text))); + return false; +} + +export function pushSegmentFinale( + target: Array, + renderState: RenderState, + lastPushedText: boolean, + textEmbedded: boolean, +): void { + // Markup doesn't need any termination. + return; +} + +export function writeStartCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeStartClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, + // flushing these error arguments are not currently supported in this legacy streaming format. + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export function writeEndCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeEndClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export type TransitionStatus = FormStatus; +export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-html/src/ReactHTMLClient.js b/packages/react-html/src/ReactHTMLClient.js new file mode 100644 index 0000000000000..533ae7a3c3e7b --- /dev/null +++ b/packages/react-html/src/ReactHTMLClient.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from './ReactFizzConfigHTML'; + +type MarkupOptions = { + identifierPrefix?: string, + signal?: AbortSignal, +}; + +export function renderToMarkup( + children: ReactNodeList, + options?: MarkupOptions, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const fizzDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + destroy(error: mixed) { + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const fizzRequest = createFizzRequest( + children, + resumableState, + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abortFizz(fizzRequest, (signal: any).reason); + } else { + const listener = () => { + abortFizz(fizzRequest, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); + }); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js index c4eebe3f51054..923881e4da755 100644 --- a/packages/react-html/src/ReactHTMLServer.js +++ b/packages/react-html/src/ReactHTMLServer.js @@ -37,7 +37,7 @@ import { createResumableState, createRenderState, createRootFormatContext, -} from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; +} from './ReactFizzConfigHTML'; type ReactMarkupNodeList = // This is the intersection of ReactNodeList and ReactClientValue minus @@ -143,7 +143,14 @@ export function renderToMarkup( // $FlowFixMe: Thenables as children are supported. root, resumableState, - createRenderState(resumableState, true), + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), createRootFormatContext(), Infinity, onError, diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js new file mode 100644 index 0000000000000..4f052288a8403 --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -0,0 +1,141 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactHTML; + +describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactHTML = require('react-html'); + }); + + it('should be able to render a simple component', async () => { + function Component() { + return
hello world
; + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
hello world
'); + }); + + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + return
{state}
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup(); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate disableClientCache + it('does NOT support cache yet because it is a client component', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return ( +
+ {a} + {b} +
+ ); + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
01
'); + }); +}); diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js index 503e233301c9a..ecb33c1c040d3 100644 --- a/packages/react-html/src/__tests__/ReactHTMLServer-test.js +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -38,6 +38,44 @@ describe('ReactHTML', () => { expect(html).toBe('
hello world
'); }); + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, state); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {ref}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {onClick}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + it('supports the useId Hook', async () => { function Component() { const firstNameId = React.useId(); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js new file mode 100644 index 0000000000000..9f70eeb70e5bc --- /dev/null +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Re-exported just because we always type check react-reconciler even in +// dimensions where it's not used. +export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('Fiber is not used in react-html'); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 05cc1a2e1bead..f5965c4f69096 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -31,7 +31,11 @@ import { readPreviousThenable, } from './ReactFizzThenable'; -import {makeId, NotPendingTransition} from './ReactFizzConfig'; +import { + makeId, + NotPendingTransition, + supportsClientAPIs, +} from './ReactFizzConfig'; import {createFastHash} from './ReactServerStreamConfig'; import { @@ -803,29 +807,56 @@ function useMemoCache(size: number): Array { function noop(): void {} -export const HooksDispatcher: Dispatcher = { - readContext, - use, - useContext, - useMemo, - useReducer, - useRef, - useState, - useInsertionEffect: noop, - useLayoutEffect: noop, - useCallback, - // useImperativeHandle is not run in the server environment - useImperativeHandle: noop, - // Effects are not run in the server environment. - useEffect: noop, - // Debugging effect - useDebugValue: noop, - useDeferredValue, - useTransition, - useId, - // Subscriptions are not setup in a server environment. - useSyncExternalStore, -}; +function clientHookNotSupported() { + throw new Error( + 'Cannot use state or effect Hooks in renderToMarkup because ' + + 'this component will never be hydrated.', + ); +} + +export const HooksDispatcher: Dispatcher = supportsClientAPIs + ? { + readContext, + use, + useContext, + useMemo, + useReducer, + useRef, + useState, + useInsertionEffect: noop, + useLayoutEffect: noop, + useCallback, + // useImperativeHandle is not run in the server environment + useImperativeHandle: noop, + // Effects are not run in the server environment. + useEffect: noop, + // Debugging effect + useDebugValue: noop, + useDeferredValue, + useTransition, + useId, + // Subscriptions are not setup in a server environment. + useSyncExternalStore, + } + : { + readContext, + use, + useContext, + useMemo, + useReducer: clientHookNotSupported, + useRef: clientHookNotSupported, + useState: clientHookNotSupported, + useInsertionEffect: clientHookNotSupported, + useLayoutEffect: clientHookNotSupported, + useCallback, + useImperativeHandle: clientHookNotSupported, + useEffect: clientHookNotSupported, + useDebugValue: noop, + useDeferredValue: clientHookNotSupported, + useTransition: clientHookNotSupported, + useId, + useSyncExternalStore: clientHookNotSupported, + }; if (enableCache) { HooksDispatcher.useCacheRefresh = useCacheRefresh; diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index c7964f187d42c..249292fd19519 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -37,6 +37,8 @@ export type {TransitionStatus}; export const isPrimaryRenderer = false; +export const supportsClientAPIs = true; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.markup.js b/packages/react-server/src/forks/ReactFizzConfig.markup.js new file mode 100644 index 0000000000000..15e35a2ef0b97 --- /dev/null +++ b/packages/react-server/src/forks/ReactFizzConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {Request} from 'react-server/src/ReactFizzServer'; + +export * from 'react-html/src/ReactFizzConfigHTML.js'; + +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index 99591bb954ea9..15874c64e0858 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -9,15 +9,15 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; -import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; -export type HintCode = string; -export type HintModel = null; // eslint-disable-line no-unused-vars -export type Hints = null; +export * from '../ReactFlightServerConfigBundlerCustom'; -export function createHints(): Hints { - return null; -} +export * from '../ReactFlightServerConfigDebugNoop'; + +export type Hints = any; +export type HintCode = any; +// eslint-disable-next-line no-unused-vars +export type HintModel = any; export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); @@ -26,65 +26,6 @@ export const supportsComponentStorage = false; export const componentStorage: AsyncLocalStorage = (null: any); -export * from '../ReactFlightServerConfigDebugNoop'; - -export type ClientManifest = null; -export opaque type ClientReference = null; // eslint-disable-line no-unused-vars -export opaque type ServerReference = null; // eslint-disable-line no-unused-vars -export opaque type ClientReferenceMetadata: any = null; -export opaque type ServerReferenceId: string = string; -export opaque type ClientReferenceKey: any = string; - -const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); -const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); - -export function isClientReference(reference: Object): boolean { - return reference.$$typeof === CLIENT_REFERENCE_TAG; -} - -export function isServerReference(reference: Object): boolean { - return reference.$$typeof === SERVER_REFERENCE_TAG; -} - -export function getClientReferenceKey( - reference: ClientReference, -): ClientReferenceKey { - throw new Error( - 'Attempted to render a Client Component from renderToMarkup. ' + - 'This is not supported since it will never hydrate. ' + - 'Only render Server Components with renderToMarkup.', - ); -} - -export function resolveClientReferenceMetadata( - config: ClientManifest, - clientReference: ClientReference, -): ClientReferenceMetadata { - throw new Error( - 'Attempted to render a Client Component from renderToMarkup. ' + - 'This is not supported since it will never hydrate. ' + - 'Only render Server Components with renderToMarkup.', - ); -} - -export function getServerReferenceId( - config: ClientManifest, - serverReference: ServerReference, -): ServerReferenceId { - throw new Error( - 'Attempted to render a Server Action from renderToMarkup. ' + - 'This is not supported since it varies by version of the app. ' + - 'Use a fixed URL for any forms instead.', - ); -} - -export function getServerReferenceBoundArguments( - config: ClientManifest, - serverReference: ServerReference, -): null | Array { - throw new Error( - 'Attempted to render a Server Action from renderToMarkup. ' + - 'This is not supported since it varies by version of the app. ' + - 'Use a fixed URL for any forms instead.', - ); +export function createHints(): any { + return null; } diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js new file mode 100644 index 0000000000000..99591bb954ea9 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Request} from 'react-server/src/ReactFlightServer'; +import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; + +export type HintCode = string; +export type HintModel = null; // eslint-disable-line no-unused-vars +export type Hints = null; + +export function createHints(): Hints { + return null; +} + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export const supportsComponentStorage = false; +export const componentStorage: AsyncLocalStorage = + (null: any); + +export * from '../ReactFlightServerConfigDebugNoop'; + +export type ClientManifest = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars +export opaque type ServerReference = null; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = null; +export opaque type ServerReferenceId: string = string; +export opaque type ClientReferenceKey: any = string; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.markup.js b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js new file mode 100644 index 0000000000000..80d5dcab2a5f2 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from 'react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig'; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 9d8fb4ed3739f..46600256b0279 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -520,5 +520,8 @@ "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.", "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.", "534": "renderToMarkup should not have emitted Client References. This is a bug in React.", - "535": "renderToMarkup should not have emitted Server References. This is a bug in React." + "535": "renderToMarkup should not have emitted Server References. This is a bug in React.", + "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.", + "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.", + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated." } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 67f1c237b209f..db0a306ffcefc 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -363,7 +363,7 @@ const bundles = [ externals: [], }, - /******* React HTML *******/ + /******* React HTML RSC *******/ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: RENDERER, @@ -376,6 +376,18 @@ const bundles = [ externals: ['react'], }, + /******* React HTML Client *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLClient.js', + name: 'react-html', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + /******* React Server DOM Webpack Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 0ba6827bba7a2..cffc66d2324f3 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -100,6 +100,7 @@ const forks = Object.freeze({ entry === 'react-dom/src/ReactDOMFB.js' || entry === 'react-dom/src/ReactDOMTestingFB.js' || entry === 'react-dom/src/ReactDOMServer.js' || + entry === 'react-html/src/ReactHTMLClient.js' || entry === 'react-html/src/ReactHTMLServer.js' ) { if ( diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a21b624150aac..d59a9bde49170 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -428,16 +428,29 @@ module.exports = [ entryPoints: [ 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node - 'react-html/src/ReactHTMLServer.js', ], paths: [ 'react-dom', 'react-dom/src/ReactDOMReactServer.js', 'react-dom-bindings', - 'react-server-dom-webpack', 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node + 'shared/ReactDOMSharedInternals', + ], + isFlowTyped: true, + isServerSupported: true, + }, + { + shortName: 'markup', + entryPoints: [ + 'react-html/src/ReactHTMLClient.js', // react-html + 'react-html/src/ReactHTMLServer.js', // react-html/react-html.react-server + ], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom-bindings', 'react-html', 'shared/ReactDOMSharedInternals', ],