diff --git a/packages/react-markup/src/__tests__/ReactMarkupAndFlight-test.js b/packages/react-markup/src/__tests__/ReactMarkupAndFlight-test.js new file mode 100644 index 0000000000000..8e3dd20974d02 --- /dev/null +++ b/packages/react-markup/src/__tests__/ReactMarkupAndFlight-test.js @@ -0,0 +1,319 @@ +/** + * 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 + * @jest-environment node + */ + +'use strict'; + +if (typeof Blob === 'undefined') { + global.Blob = require('buffer').Blob; +} +if (typeof File === 'undefined' || typeof FormData === 'undefined') { + global.File = require('undici').File; + global.FormData = require('undici').FormData; +} + +let act; +let React; +let ReactServer; +let ReactMarkup; +let ReactNoop; +let ReactNoopFlightServer; +let ReactNoopFlightClient; + +function normalizeCodeLocInfo(str) { + return ( + str && + String(str).replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) { + return '\n in ' + name + ' (at **)'; + }) + ); +} + +if (!__EXPERIMENTAL__) { + it('should not be built in stable', () => { + try { + require('react-markup'); + } catch (x) { + return; + } + throw new Error('Expected react-markup not to exist in stable.'); + }); +} else { + describe('ReactMarkupAndFlight', () => { + beforeEach(() => { + jest.resetModules(); + jest.mock('react', () => require('react/react.react-server')); + ReactServer = require('react'); + ReactNoopFlightServer = require('react-noop-renderer/flight-server'); + // This stores the state so we need to preserve it + const flightModules = require('react-noop-renderer/flight-modules'); + if (__EXPERIMENTAL__) { + jest.resetModules(); + jest.mock('react', () => ReactServer); + jest.mock('react-markup', () => + require('react-markup/react-markup.react-server'), + ); + ReactMarkup = require('react-markup'); + } + jest.resetModules(); + __unmockReact(); + jest.mock('react-noop-renderer/flight-modules', () => flightModules); + React = require('react'); + ReactNoop = require('react-noop-renderer'); + ReactNoopFlightClient = require('react-noop-renderer/flight-client'); + act = require('internal-test-utils').act; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('supports using react-markup', async () => { + async function Preview() { + const html = + await ReactMarkup.experimental_renderToHTML('Hello, Dave!'); + + return
{html}
; + } + + const model = ; + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + // So it throws here with "Cannot read properties of null (reading 'length')" + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput(
Hello, Dave!
); + }); + + it('has a cache if the first renderer is used standalone', async () => { + let n = 0; + const uncachedFunction = jest.fn(() => { + return n++; + }); + const random = ReactServer.cache(uncachedFunction); + + function Random() { + return random(); + } + + function App() { + return ( + <> +

+ RSC A_1: +

+

+ RSC A_2: +

+ + ); + } + + const model = ; + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput( + <> +

RSC A_1: 0

+

RSC A_2: 0

+ , + ); + expect(uncachedFunction).toHaveBeenCalledTimes(1); + }); + + it('has a cache if the second renderer is used standalone', async () => { + let n = 0; + const uncachedFunction = jest.fn(() => { + return n++; + }); + const random = ReactServer.cache(uncachedFunction); + + function Random() { + return random(); + } + + function App() { + return ( + <> +

+ RSC B_1: +

+

+ RSC B_2: +

+ + ); + } + + const html = await ReactMarkup.experimental_renderToHTML( + ReactServer.createElement(App), + ); + + expect(html).toEqual('

RSC B_1: 0

RSC B_2: 0

'); + expect(uncachedFunction).toHaveBeenCalledTimes(1); + }); + + it('shares cache between RSC renderers', async () => { + let n = 0; + const uncachedFunction = jest.fn(() => { + return n++; + }); + const random = ReactServer.cache(uncachedFunction); + + function Random() { + return random(); + } + + async function Preview() { + const html = await ReactMarkup.experimental_renderToHTML(); + + return ( + <> +

+ RSC A: +

+

RSC B: {html}

+ + ); + } + + function App() { + return ( + <> + + + + ); + } + + const model = ; + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(ReactNoop).toMatchRenderedOutput( + <> +

RSC A: 0

+

RSC B: 0

+

RSC A: 0

+

RSC B: 0

+ , + ); + expect(uncachedFunction).toHaveBeenCalledTimes(1); + }); + + it('shows correct stacks in nested RSC renderers', async () => { + const thrownError = new Error('hi'); + + const caughtNestedRendererErrors = []; + const ownerStacksDuringParentRendererThrow = []; + const ownerStacksDuringNestedRendererThrow = []; + function Throw() { + if (gate(flags => flags.enableOwnerStacks)) { + const stack = ReactServer.captureOwnerStack(); + ownerStacksDuringNestedRendererThrow.push( + normalizeCodeLocInfo(stack), + ); + } + throw thrownError; + } + + function Indirection() { + return ReactServer.createElement(Throw); + } + + function App() { + return ReactServer.createElement(Indirection); + } + + async function Preview() { + try { + await ReactMarkup.experimental_renderToHTML( + ReactServer.createElement(App), + { + onError: (error, errorInfo) => { + caughtNestedRendererErrors.push({ + error: error, + parentStack: errorInfo.componentStack, + ownerStack: gate(flags => flags.enableOwnerStacks) + ? ReactServer.captureOwnerStack() + : null, + }); + }, + }, + ); + } catch (error) { + let stack = ''; + if (gate(flags => flags.enableOwnerStacks)) { + stack = ReactServer.captureOwnerStack(); + ownerStacksDuringParentRendererThrow.push( + normalizeCodeLocInfo(stack), + ); + } + + return 'did error'; + } + } + + function PreviewApp() { + return ReactServer.createElement(Preview); + } + + const model = ReactServer.createElement(PreviewApp); + const transport = ReactNoopFlightServer.render(model); + + await act(async () => { + ReactNoop.render(await ReactNoopFlightClient.read(transport)); + }); + + expect(caughtNestedRendererErrors).toEqual([ + { + error: thrownError, + ownerStack: + __DEV__ && gate(flags => flags.enableOwnerStacks) + ? // TODO: Shouldn't this read the same as the one we got during render? + '' + : null, + // TODO: Shouldn't a parent stack exist? + parentStack: undefined, + }, + ]); + expect(ownerStacksDuringParentRendererThrow).toEqual( + gate(flags => flags.enableOwnerStacks) + ? [ + __DEV__ + ? // TODO: Should have an owner stack + '' + : null, + ] + : [], + ); + expect(ownerStacksDuringNestedRendererThrow).toEqual( + gate(flags => flags.enableOwnerStacks) + ? [ + __DEV__ + ? '\n in Indirection (at **)' + + '\n in App (at **)' + + '\n in Preview (at **)' + + '\n in PreviewApp (at **)' + : null, + ] + : [], + ); + }); + }); +} diff --git a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js index d329fb369cae3..9d588ceb49179 100644 --- a/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js +++ b/packages/react-reconciler/src/ReactFiberAsyncDispatcher.js @@ -7,7 +7,7 @@ * @flow */ -import type {AsyncDispatcher, Fiber} from './ReactInternalTypes'; +import type {AsyncCache, AsyncDispatcher, Fiber} from './ReactInternalTypes'; import type {Cache} from './ReactFiberCacheComponent'; import {enableCache} from 'shared/ReactFeatureFlags'; @@ -18,21 +18,18 @@ import {disableStringRefs} from 'shared/ReactFeatureFlags'; import {current as currentOwner} from './ReactCurrentFiber'; -function getCacheForType(resourceType: () => T): T { +function getActiveCache(): AsyncCache { if (!enableCache) { throw new Error('Not implemented.'); } + const cache: Cache = readContext(CacheContext); - let cacheForType: T | void = (cache.data.get(resourceType): any); - if (cacheForType === undefined) { - cacheForType = resourceType(); - cache.data.set(resourceType, cacheForType); - } - return cacheForType; + + return cache.data; } export const DefaultAsyncDispatcher: AsyncDispatcher = ({ - getCacheForType, + getActiveCache, }: any); if (__DEV__ || !disableStringRefs) { diff --git a/packages/react-reconciler/src/ReactFiberCacheComponent.js b/packages/react-reconciler/src/ReactFiberCacheComponent.js index 64269c5785d13..67031db3b242c 100644 --- a/packages/react-reconciler/src/ReactFiberCacheComponent.js +++ b/packages/react-reconciler/src/ReactFiberCacheComponent.js @@ -8,7 +8,7 @@ */ import type {ReactContext} from 'shared/ReactTypes'; -import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; +import type {AsyncCache, Fiber} from 'react-reconciler/src/ReactInternalTypes'; import {enableCache} from 'shared/ReactFeatureFlags'; import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols'; @@ -42,7 +42,7 @@ const AbortControllerLocal: typeof AbortController = enableCache export type Cache = { controller: AbortController, - data: Map<() => mixed, mixed>, + data: AsyncCache, refCount: number, }; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 4549253ba79b6..8ed9ee4b126d7 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -451,8 +451,13 @@ export type Dispatcher = { ) => [Awaited, (P) => void, boolean], }; +export interface AsyncCache { + get(resourceType: Function): mixed; + set(resourceType: Function, value: mixed): AsyncCache; +} + export type AsyncDispatcher = { - getCacheForType: (resourceType: () => T) => T, + getActiveCache: () => AsyncCache | null, // DEV-only (or !disableStringRefs) getOwner: () => null | Fiber | ReactComponentInfo | ComponentStackNode, }; diff --git a/packages/react-server/src/ReactFizzAsyncDispatcher.js b/packages/react-server/src/ReactFizzAsyncDispatcher.js index 3a548ae138039..d37acf770078e 100644 --- a/packages/react-server/src/ReactFizzAsyncDispatcher.js +++ b/packages/react-server/src/ReactFizzAsyncDispatcher.js @@ -7,19 +7,22 @@ * @flow */ -import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes'; +import type { + AsyncCache, + AsyncDispatcher, +} from 'react-reconciler/src/ReactInternalTypes'; import type {ComponentStackNode} from './ReactFizzComponentStack'; import {disableStringRefs} from 'shared/ReactFeatureFlags'; import {currentTaskInDEV} from './ReactFizzCurrentTask'; -function getCacheForType(resourceType: () => T): T { +function getActiveCache(): AsyncCache | null { throw new Error('Not implemented.'); } export const DefaultAsyncDispatcher: AsyncDispatcher = ({ - getCacheForType, + getActiveCache, }: any); if (__DEV__) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index df811a8c7fa99..2866c3a59a0be 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -10,10 +10,12 @@ import type {Chunk, BinaryChunk, Destination} from './ReactServerStreamConfig'; import type {Postpone} from 'react/src/ReactPostpone'; +import type {AsyncCache} from 'react-reconciler/src/ReactInternalTypes'; import type {TemporaryReferenceSet} from './ReactFlightServerTemporaryReferences'; import { + disableStringRefs, enableBinaryFlight, enablePostpone, enableHalt, @@ -448,15 +450,24 @@ function RequestInstance( onAllReady: () => void, onFatalError: (error: mixed) => void, ) { - if ( - ReactSharedInternals.A !== null && - ReactSharedInternals.A !== DefaultAsyncDispatcher - ) { - throw new Error( - 'Currently React only supports one RSC renderer at a time.', - ); + const previousAsyncDispatcher = ReactSharedInternals.A; + + if (previousAsyncDispatcher !== null) { + const previousActiveCache = previousAsyncDispatcher.getActiveCache(); + if (previousActiveCache !== null) { + ReactSharedInternals.A = ({ + getActiveCache(): AsyncCache | null { + return previousActiveCache; + }, + }: any); + if (__DEV__ || !disableStringRefs) { + ReactSharedInternals.A.getOwner = DefaultAsyncDispatcher.getOwner; + } + } + } else { + ReactSharedInternals.A = DefaultAsyncDispatcher; } - ReactSharedInternals.A = DefaultAsyncDispatcher; + if (__DEV__) { // Unlike Fizz or Fiber, we don't reset this and just keep it on permanently. // This lets it act more like the AsyncDispatcher so that we can get the diff --git a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js index f5f031a860ff9..190304cc24b6b 100644 --- a/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js +++ b/packages/react-server/src/flight/ReactFlightAsyncDispatcher.js @@ -9,7 +9,10 @@ import type {ReactComponentInfo} from 'shared/ReactTypes'; -import type {AsyncDispatcher} from 'react-reconciler/src/ReactInternalTypes'; +import type { + AsyncCache, + AsyncDispatcher, +} from 'react-reconciler/src/ReactInternalTypes'; import {resolveRequest, getCache} from '../ReactFlightServer'; @@ -17,25 +20,16 @@ import {disableStringRefs} from 'shared/ReactFeatureFlags'; import {resolveOwner} from './ReactFlightCurrentOwner'; -function resolveCache(): Map { +function getActiveCache(): AsyncCache | null { const request = resolveRequest(); if (request) { return getCache(request); } - return new Map(); + return null; } export const DefaultAsyncDispatcher: AsyncDispatcher = ({ - getCacheForType(resourceType: () => T): T { - const cache = resolveCache(); - let entry: T | void = (cache.get(resourceType): any); - if (entry === undefined) { - entry = resourceType(); - // TODO: Warn if undefined? - cache.set(resourceType, entry); - } - return entry; - }, + getActiveCache, }: any); if (__DEV__) { diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index bc2d4bee79d02..e14623807f5ad 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -13,14 +13,8 @@ import ReactSharedInternals from 'shared/ReactSharedInternals'; export function waitForSuspense(fn: () => T): Promise { const cache: Map = new Map(); const testDispatcher: AsyncDispatcher = { - getCacheForType(resourceType: () => R): R { - let entry: R | void = (cache.get(resourceType): any); - if (entry === undefined) { - entry = resourceType(); - // TODO: Warn if undefined? - cache.set(resourceType, entry); - } - return entry; + getActiveCache() { + return cache; }, getOwner(): null { return null; diff --git a/packages/react/src/ReactCacheImpl.js b/packages/react/src/ReactCacheImpl.js index cc3136897e350..e71b5d3c72e47 100644 --- a/packages/react/src/ReactCacheImpl.js +++ b/packages/react/src/ReactCacheImpl.js @@ -60,9 +60,18 @@ export function cache, T>(fn: (...A) => T): (...A) => T { // $FlowFixMe[incompatible-call]: We don't want to use rest arguments since we transpile the code. return fn.apply(null, arguments); } - const fnMap: WeakMap> = dispatcher.getCacheForType( - createCacheRoot, - ); + const activeCache = dispatcher.getActiveCache(); + let fnMap: WeakMap> | void = + activeCache !== null + ? (activeCache.get(createCacheRoot): any) + : undefined; + if (fnMap === undefined) { + fnMap = createCacheRoot(); + if (activeCache !== null) { + // TODO: Warn if undefined? + activeCache.set(createCacheRoot, fnMap); + } + } const fnNode = fnMap.get(fn); let cacheNode: CacheNode; if (fnNode === undefined) { diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 956a2a96b44a1..6543fd95a5ef3 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -53,7 +53,17 @@ export function getCacheForType(resourceType: () => T): T { // If there is no dispatcher, then we treat this as not being cached. return resourceType(); } - return dispatcher.getCacheForType(resourceType); + const activeCache = dispatcher.getActiveCache(); + let entry: T | void = + activeCache !== null ? (activeCache.get(resourceType): any) : undefined; + if (entry === undefined) { + entry = resourceType(); + if (activeCache !== null) { + // TODO: Warn if undefined? + activeCache.set(resourceType, entry); + } + } + return entry; } export function useContext(Context: ReactContext): T {