}>
+
+
+ {[0, 1, 2, 3, 4].map((index) => (
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default RedisReceiver;
diff --git a/react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts b/react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
index beeeac14c..bf8b84369 100644
--- a/react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
+++ b/react_on_rails_pro/spec/dummy/client/app/utils/redisReceiver.ts
@@ -1,5 +1,7 @@
import { createClient, RedisClientType } from 'redis';
+const REDIS_READ_TIMEOUT = 10000;
+
/**
* Redis xRead result message structure
*/
@@ -292,7 +294,7 @@ export function listenToRequestData(requestId: string): RequestListener {
);
// Keep the pending promise in the dictionary with the error state
}
- }, 8000);
+ }, REDIS_READ_TIMEOUT);
// Store the promise and its controllers
if (resolvePromise && rejectPromise) {
diff --git a/react_on_rails_pro/spec/dummy/config/routes.rb b/react_on_rails_pro/spec/dummy/config/routes.rb
index 2df51415a..1d2f2b4e0 100644
--- a/react_on_rails_pro/spec/dummy/config/routes.rb
+++ b/react_on_rails_pro/spec/dummy/config/routes.rb
@@ -20,6 +20,8 @@
get "apollo_graphql" => "pages#apollo_graphql", as: :apollo_graphql
get "lazy_apollo_graphql" => "pages#lazy_apollo_graphql", as: :lazy_apollo_graphql
get "console_logs_in_async_server" => "pages#console_logs_in_async_server", as: :console_logs_in_async_server
+ get "redis_receiver" => "pages#redis_receiver", as: :redis_receiver
+ get "redis_receiver_for_testing" => "pages#redis_receiver_for_testing", as: :redis_receiver_for_testing
get "stream_async_components" => "pages#stream_async_components", as: :stream_async_components
get "stream_async_components_for_testing" => "pages#stream_async_components_for_testing",
as: :stream_async_components_for_testing
diff --git a/react_on_rails_pro/spec/dummy/e2e-tests/fixture.ts b/react_on_rails_pro/spec/dummy/e2e-tests/fixture.ts
new file mode 100644
index 000000000..874bdbc6d
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/e2e-tests/fixture.ts
@@ -0,0 +1,153 @@
+import { randomUUID } from 'crypto';
+import { test as base, Response, expect, Request } from '@playwright/test';
+import { createClient, RedisClientType } from 'redis';
+
+type RedisClientFixture = {
+ redisClient: RedisClientType;
+};
+
+type RedisRequestIdFixture = {
+ redisRequestId: string;
+ nonBlockingNavigateWithRequestId: (path: string) => Promise;
+};
+
+type RedisReceiverPageFixture = {
+ pagePath: string;
+};
+
+export type RedisReceiverControllerFixture = {
+ sendRedisValue: (key: string, value: unknown) => Promise;
+ sendRedisItemValue: (itemIndex: number, value: unknown) => Promise;
+ matchPageSnapshot: (snapshotPath: string) => Promise;
+ waitForConsoleMessage: (msg: string) => Promise;
+ getNetworkRequests: (requestUrlPattern: RegExp) => Promise;
+};
+
+const redisControlledTest = base.extend({
+ redisClient: [
+ async ({}, use, workerInfo) => {
+ console.log(`Creating Redis Client at Worker ${workerInfo.workerIndex}`);
+ const url = process.env.REDIS_URL || 'redis://localhost:6379';
+ const client = createClient({ url });
+ await client.connect();
+ await use(client as RedisClientType);
+ await client.quit();
+ },
+ { scope: 'worker' },
+ ],
+
+ redisRequestId: async ({ redisClient }, use) => {
+ const id = randomUUID();
+ try {
+ await use(id);
+ } finally {
+ // cleanup of the stream for this request
+ await redisClient.del(`stream:${id}`);
+ }
+ },
+
+ nonBlockingNavigateWithRequestId: async ({ redisRequestId, page }, use) => {
+ await use((path) => {
+ const requestIdParam = `request_id=${redisRequestId}`;
+ const fullPath = path.includes('?') ? `${path}&${requestIdParam}` : `${path}?${requestIdParam}`;
+ return page.goto(fullPath, { waitUntil: 'commit' });
+ });
+ },
+});
+
+const redisReceiverPageController = redisControlledTest.extend({
+ sendRedisValue: async ({ redisClient, redisRequestId }, use) => {
+ await use(async (key, value) => {
+ await redisClient.xAdd(`stream:${redisRequestId}`, '*', { [`:${key}`]: JSON.stringify(value) });
+ });
+ },
+ sendRedisItemValue: async ({ sendRedisValue }, use) => {
+ await use(async (itemIndex, value) => {
+ await sendRedisValue(`Item${itemIndex}`, value);
+ });
+ },
+ matchPageSnapshot: async ({ page }, use) => {
+ await use(async (snapshotPath) => {
+ await expect(page.locator('.redis-receiver-container:visible')).toBeVisible();
+ await expect(page.locator('.redis-receiver-container:visible').first()).toMatchAriaSnapshot({
+ name: `${snapshotPath}.aria.yml`,
+ });
+ });
+ },
+ waitForConsoleMessage: async ({ page }, use) => {
+ await use(async (msg) => {
+ if ((await page.consoleMessages()).find((consoleMsg) => consoleMsg.text().includes(msg))) {
+ return;
+ }
+
+ await page.waitForEvent('console', {
+ predicate: (consoleMsg) => consoleMsg.text().includes(msg),
+ });
+ });
+ },
+ getNetworkRequests: async ({ page }, use) => {
+ await use(async (requestUrlPattern) => {
+ return (await page.requests()).filter((request) => request.url().match(requestUrlPattern));
+ });
+ },
+});
+
+const redisReceiverPageTest = redisReceiverPageController.extend({
+ pagePath: [
+ async ({ nonBlockingNavigateWithRequestId }, use) => {
+ const pagePath = '/redis_receiver_for_testing';
+ await nonBlockingNavigateWithRequestId(pagePath);
+ await use(pagePath);
+ },
+ { auto: true },
+ ],
+});
+
+const redisReceiverPageWithAsyncClientComponentTest =
+ redisReceiverPageController.extend({
+ pagePath: [
+ async ({ page, nonBlockingNavigateWithRequestId, sendRedisValue }, use) => {
+ const pagePath = '/redis_receiver_for_testing?async_toggle_container=true';
+ await nonBlockingNavigateWithRequestId(pagePath);
+
+ await expect(page.getByText('Loading ToggleContainer')).toBeVisible();
+ await expect(page.locator('.toggle-button')).not.toBeVisible();
+
+ await sendRedisValue('ToggleContainer', 'anything');
+ await expect(page.locator('.toggle-button')).toBeVisible();
+ await use(pagePath);
+ },
+ { auto: true },
+ ],
+ });
+
+const redisReceiverInsideRouterPageTest = redisReceiverPageController.extend({
+ pagePath: [
+ async ({ nonBlockingNavigateWithRequestId }, use) => {
+ const pagePath = '/server_router/redis-receiver-for-testing';
+ await nonBlockingNavigateWithRequestId(pagePath);
+ await use(pagePath);
+ },
+ { auto: true },
+ ],
+});
+
+const redisReceiverPageAfterNavigationTest = redisReceiverPageController.extend({
+ pagePath: [
+ async ({ nonBlockingNavigateWithRequestId, page }, use) => {
+ await nonBlockingNavigateWithRequestId('/server_router/simple-server-component');
+ await expect(page.getByText('Post 1')).toBeVisible({ timeout: 3000 });
+ await page.getByText('Redis Receiver For Testing').click();
+ await use('/server_router/redis-receiver-for-testing');
+ },
+ { auto: true },
+ ],
+});
+
+export {
+ redisReceiverPageController,
+ redisReceiverPageTest,
+ redisReceiverInsideRouterPageTest,
+ redisReceiverPageAfterNavigationTest,
+ redisReceiverPageWithAsyncClientComponentTest,
+};
diff --git a/react_on_rails_pro/spec/dummy/e2e-tests/streaming.spec.ts b/react_on_rails_pro/spec/dummy/e2e-tests/streaming.spec.ts
new file mode 100644
index 000000000..0bd5ea2a6
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/e2e-tests/streaming.spec.ts
@@ -0,0 +1,103 @@
+import { expect } from '@playwright/test';
+import {
+ redisReceiverPageController,
+ redisReceiverPageTest,
+ redisReceiverInsideRouterPageTest,
+ redisReceiverPageAfterNavigationTest,
+ redisReceiverPageWithAsyncClientComponentTest,
+} from './fixture';
+
+// Snapshot testing the best testing strategy for our use case
+// Because we need to ensure that any transformation done on the HTML or RSC payload stream won't affect
+// - Order of fallback or components at the page
+// - Any update chunk won't affect previously rendered parts of the page
+// - Rendered component won't get back to its fallback component at any stage of the page
+// - Snapshot testing saves huge number of complex assertions
+(
+ [
+ ['RedisReceiver', redisReceiverPageTest],
+ ['RedisReceiver inside router page', redisReceiverInsideRouterPageTest],
+ ['RedisReceiver inside router after navigation', redisReceiverPageAfterNavigationTest],
+ [
+ 'RedisReceiver with Async Toggle Container Client Component',
+ redisReceiverPageWithAsyncClientComponentTest,
+ ],
+ ] as const
+).forEach(([pageName, test]) => {
+ test(`incremental rendering of page: ${pageName}`, async ({ matchPageSnapshot, sendRedisItemValue }) => {
+ await matchPageSnapshot('stage0');
+
+ await sendRedisItemValue(0, 'Incremental Value1');
+ await matchPageSnapshot('stage1');
+
+ await sendRedisItemValue(3, 'Incremental Value4');
+ await matchPageSnapshot('stage2');
+
+ await sendRedisItemValue(1, 'Incremental Value2');
+ await matchPageSnapshot('stage3');
+
+ await sendRedisItemValue(2, 'Incremental Value3');
+ await matchPageSnapshot('stage4');
+
+ await sendRedisItemValue(4, 'Incremental Value5');
+ await matchPageSnapshot('stage5');
+ });
+
+ test(`early hydration of page: ${pageName}`, async ({
+ page,
+ waitForConsoleMessage,
+ matchPageSnapshot,
+ sendRedisItemValue,
+ }) => {
+ await waitForConsoleMessage('ToggleContainer with title');
+
+ await page.click('.toggle-button');
+ await expect(page.getByText(/Waiting for the key "Item\d"/)).not.toBeVisible();
+
+ await page.click('.toggle-button');
+ const fallbackElements = page.getByText(/Waiting for the key "Item\d"/);
+ await expect(fallbackElements).toHaveCount(5);
+ await Promise.all((await fallbackElements.all()).map((el) => expect(el).toBeVisible()));
+ await matchPageSnapshot('stage0');
+
+ await sendRedisItemValue(0, 'Incremental Value1');
+ await matchPageSnapshot('stage1');
+
+ await sendRedisItemValue(3, 'Incremental Value4');
+ await matchPageSnapshot('stage2');
+
+ await sendRedisItemValue(1, 'Incremental Value2');
+ await matchPageSnapshot('stage3');
+
+ await sendRedisItemValue(2, 'Incremental Value3');
+ await matchPageSnapshot('stage4');
+
+ await sendRedisItemValue(4, 'Incremental Value5');
+ await matchPageSnapshot('stage5');
+ });
+});
+
+redisReceiverInsideRouterPageTest(
+ 'no RSC payload request is made when the page is server side rendered',
+ async ({ getNetworkRequests }) => {
+ expect(await getNetworkRequests(/rsc_payload/)).toHaveLength(0);
+ },
+);
+
+redisReceiverPageAfterNavigationTest(
+ 'RSC payload request is made on navigation',
+ async ({ getNetworkRequests }) => {
+ expect(await getNetworkRequests(/rsc_payload/)).toHaveLength(1);
+ },
+);
+
+redisReceiverPageController(
+ 'client side rendered router fetches RSC payload',
+ async ({ page, getNetworkRequests }) => {
+ await page.goto('/server_router_client_render/simple-server-component');
+
+ await expect(page.getByText('Post 1')).toBeVisible();
+ await expect(page.getByText('Toggle')).toBeVisible();
+ expect(await getNetworkRequests(/rsc_payload/)).toHaveLength(1);
+ },
+);
diff --git a/react_on_rails_pro/spec/dummy/package.json b/react_on_rails_pro/spec/dummy/package.json
index 1ae3c8e2b..055bc0247 100644
--- a/react_on_rails_pro/spec/dummy/package.json
+++ b/react_on_rails_pro/spec/dummy/package.json
@@ -77,11 +77,13 @@
},
"devDependencies": {
"@babel/preset-typescript": "^7.23.2",
+ "@playwright/test": "^1.56.1",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@tsconfig/create-react-app": "^2.0.1",
+ "@types/node": "^24.8.1",
"@types/react-dom": "^18.2.14",
"jsdom": "^16.4.0",
"pino-pretty": "^13.0.0",
@@ -95,6 +97,7 @@
"scripts": {
"test": "yarn run build:test && yarn run lint && rspec",
"lint": "cd ../.. && nps lint",
+ "e2e-test": "playwright test",
"preinstall": "yarn run link-source && yalc add --link react-on-rails-pro && cd .yalc/react-on-rails-pro && yalc add --link react-on-rails && cd ../.. && yalc add --link @shakacode-tools/react-on-rails-pro-node-renderer",
"link-source": "cd ../../.. && yarn && yarn run yalc:publish && cd react_on_rails_pro && yarn && yalc publish",
"postinstall": "test -f post-yarn-install.local && ./post-yarn-install.local || true",
diff --git a/react_on_rails_pro/spec/dummy/playwright.config.ts b/react_on_rails_pro/spec/dummy/playwright.config.ts
new file mode 100644
index 000000000..10e3eaa08
--- /dev/null
+++ b/react_on_rails_pro/spec/dummy/playwright.config.ts
@@ -0,0 +1,84 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// import dotenv from 'dotenv';
+// import path from 'path';
+// dotenv.config({ path: path.resolve(__dirname, '.env') });
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ expect: {
+ toMatchAriaSnapshot: {
+ pathTemplate: '__snapshots__/{testFilePath}/{arg}{ext}',
+ },
+ },
+ testDir: './e2e-tests',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: process.env.CI ? [['html'], ['junit', { outputFile: 'test-results/results.xml' }]] : 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('')`. */
+ baseURL: 'http://localhost:3000/',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+
+ /* Test against mobile viewports. */
+ // {
+ // name: 'Mobile Chrome',
+ // use: { ...devices['Pixel 5'] },
+ // },
+ // {
+ // name: 'Mobile Safari',
+ // use: { ...devices['iPhone 12'] },
+ // },
+
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ // webServer: {
+ // command: 'npm run start',
+ // url: 'http://localhost:3000',
+ // reuseExistingServer: !process.env.CI,
+ // },
+});
diff --git a/react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb b/react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
index 839c8b9a1..3df3178f7 100644
--- a/react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
+++ b/react_on_rails_pro/spec/dummy/spec/system/integration_spec.rb
@@ -413,79 +413,6 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false)
expect(page).to have_text(/branch1 \(level \d+\)/, count: 5)
end
- shared_examples "shows loading fallback while rendering async components" do |skip_js_packs|
- it "shows the loading fallback while rendering async components" \
- "#{skip_js_packs ? ' when the page is not hydrated' : ''}" do
- path = "#{path}#{skip_js_packs ? '?skip_js_packs=true' : ''}"
- chunks_count = 0
- chunks_count_having_branch1_loading_fallback = 0
- chunks_count_having_branch2_loading_fallback = 0
- navigate_with_streaming(path) do |_content|
- chunks_count += 1
- chunks_count_having_branch1_loading_fallback += 1 if page.has_text?(/Loading branch1 at level \d+/)
- chunks_count_having_branch2_loading_fallback += 1 if page.has_text?(/Loading branch2 at level \d+/)
- end
-
- expect(chunks_count_having_branch1_loading_fallback).to be_between(3, 6)
- expect(chunks_count_having_branch2_loading_fallback).to be_between(1, 3)
- expect(page).not_to have_text(/Loading branch1 at level \d+/)
- expect(page).not_to have_text(/Loading branch2 at level \d+/)
- expect(chunks_count).to be_between(5, 7)
-
- # Check if the page is hydrated or not
- change_text_expect_dom_selector(selector, expect_no_change: skip_js_packs)
- end
- end
-
- it_behaves_like "shows loading fallback while rendering async components", false
- it_behaves_like "shows loading fallback while rendering async components", true
-
- it "replays console logs" do
- visit path
- logs = page.driver.browser.logs.get(:browser)
- info = logs.select { |log| log.level == "INFO" }
- info_messages = info.map(&:message)
- errors = logs.select { |log| log.level == "SEVERE" }
- errors_messages = errors.map(&:message)
-
- expect(info_messages).to include(/\[SERVER\] Sync console log from AsyncComponentsTreeForTesting/)
- 5.times do |i|
- expect(info_messages).to include(/\[SERVER\] branch1 \(level #{i}\)/)
- expect(errors_messages).to include(
- /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":#{i}}"/
- )
- end
- 2.times do |i|
- expect(info_messages).to include(/\[SERVER\] branch2 \(level #{i}\)/)
- expect(errors_messages).to include(
- /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch2\\",\\"level\\":#{i}}"/
- )
- end
- end
-
- it "replays console logs with each chunk" do
- chunks_count = 0
- chunks_count_containing_server_logs = 0
- navigate_with_streaming(path) do |content|
- chunks_count += 1
- logs = page.driver.browser.logs.get(:browser)
- info = logs.select { |log| log.level == "INFO" }
- info_messages = info.map(&:message)
- errors = logs.select { |log| log.level == "SEVERE" }
- errors_messages = errors.map(&:message)
-
- next if content.empty? || chunks_count == 1
-
- if info_messages.any?(/\[SERVER\] branch1 \(level \d+\)/) && errors_messages.any?(
- /"\[SERVER\] Error message" "{\\"branchName\\":\\"branch1\\",\\"level\\":\d+}/
- )
- chunks_count_containing_server_logs += 1
- end
- end
- expect(chunks_count).to be >= 5
- expect(chunks_count_containing_server_logs).to be > 2
- end
-
it "doesn't hydrate status component if packs are not loaded" do
# visit waits for the page to load, so we ensure that the page is loaded before checking the hydration status
visit "#{path}?skip_js_packs=true"
@@ -493,35 +420,6 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false)
expect(page).not_to have_text "HydrationStatus: Hydrated"
expect(page).not_to have_text "HydrationStatus: Page loaded"
end
-
- it "hydrates loaded components early before the full page is loaded" do
- chunks_count = 0
- status_component_hydrated_on_chunk = nil
- input_component_hydrated_on_chunk = nil
- navigate_with_streaming(path) do |_content|
- chunks_count += 1
-
- # The code that updates the states to Hydrated is executed on `useEffect` which is called only on hydration
- if status_component_hydrated_on_chunk.nil? && page.has_text?("HydrationStatus: Hydrated")
- status_component_hydrated_on_chunk = chunks_count
- end
-
- if input_component_hydrated_on_chunk.nil?
- begin
- # Checks that the input field is hydrated
- change_text_expect_dom_selector(selector)
- input_component_hydrated_on_chunk = chunks_count
- rescue RSpec::Expectations::ExpectationNotMetError, Capybara::ElementNotFound
- # Do nothing if the test fails - component not yet hydrated
- end
- end
- end
-
- # The component should be hydrated before the full page is loaded
- expect(status_component_hydrated_on_chunk).to be < chunks_count
- expect(input_component_hydrated_on_chunk).to be < chunks_count
- expect(page).to have_text "HydrationStatus: Page loaded"
- end
end
describe "Pages/stream_async_components_for_testing", :js do
@@ -533,322 +431,3 @@ def change_text_expect_dom_selector(dom_selector, expect_no_change: false)
it_behaves_like "streamed component tests", "/server_router/streaming-server-component",
"#ServerComponentRouter-react-component-0"
end
-
-def rsc_payload_fetch_requests
- fetch_requests_while_streaming.select { |request| request[:url].include?("/rsc_payload/") }
-end
-
-shared_examples "RSC payload only fetched if component is not server-side rendered" do |server_rendered_path,
- client_rendered_path|
- before do
- # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation
- page.driver.browser.logs.get(:browser)
- end
-
- it "doesn't fetch RSC payload if component is server-side rendered" do
- navigate_with_streaming server_rendered_path
-
- expect(rsc_payload_fetch_requests).to eq([])
- end
-
- it "fetches RSC payload if component is client-side rendered" do
- navigate_with_streaming client_rendered_path
-
- expect(rsc_payload_fetch_requests.size).to be > 0
- end
-end
-
-describe "Pages/server_router/streaming-server-component rsc payload fetching", :js do
- it_behaves_like "RSC payload only fetched if component is not server-side rendered", "/server_router/sixth",
- "/server_router_client_render/streaming-server-component"
-end
-
-describe "Pages/stream_async_components_for_testing rsc payload fetching", :js do
- it_behaves_like "RSC payload only fetched if component is not server-side rendered",
- "/stream_async_components_for_testing", "/stream_async_components_for_testing_client_render"
-end
-
-describe "Pages/server_router", :js do
- subject { page }
-
- it "navigates between pages" do
- navigate_with_streaming("/server_router/simple-server-component")
- expect_client_component_inside_server_component_hydrated(page)
- expect(page).not_to have_text("Server Component Title")
- expect(page).not_to have_text("Server Component Description")
- expect(rsc_payload_fetch_requests).to eq([])
-
- click_link "Another Simple Server Component"
- expect(rsc_payload_fetch_requests).to eq([
- { url: "/rsc_payload/MyServerComponent?props=%7B%7D" }
- ])
-
- expect(page).to have_text("Server Component Title")
- expect(page).to have_text("Server Component Description")
- expect(page).not_to have_text("Post 1")
- expect(page).not_to have_text("Content 1")
- end
-
- it "streams the navigation between pages" do
- navigate_with_streaming("/server_router/simple-server-component")
-
- click_link "Server Component with visible streaming behavior"
- expect(rsc_payload_fetch_requests.first[:url]).to include("/rsc_payload/AsyncComponentsTreeForTesting")
-
- expect(page).not_to have_text("Post 1")
- expect(page).not_to have_text("Content 1")
- expect(page).to have_text("Loading branch1 at level 3...", wait: 5)
-
- # Client component is hydrated before the full page is loaded
- expect(page).to have_text("HydrationStatus: Hydrated")
- change_text_expect_dom_selector("#ServerComponentRouter-react-component-0")
-
- expect(page).to have_text("Loading branch1 at level 1...", wait: 5)
- expect(page).to have_text("branch1 (level 1)")
- expect(page).not_to have_text("Loading branch1 at level 1...")
- expect(page).not_to have_text("Loading branch1 at level 3...")
- end
-end
-
-def async_on_server_sync_on_client_client_render_logs
- logs = page.driver.browser.logs.get(:browser)
- component_logs = logs.select { |log| log.message.include?(component_logs_tag) }
- client_component_logs = component_logs.reject { |log| log.message.include?("[SERVER]") }
- client_component_logs.map do |log|
- # Extract string between double quotes that contains component_logs_tag
- # The string can contain escaped double quotes (\").
- message = log.message.match(/"([^"]*(?:\\"[^"]*)*#{component_logs_tag}[^"]*(?:\\"[^"]*)*)"/)[1]
- JSON.parse("\"#{message}\"").gsub(component_logs_tag, "").strip
- end
-end
-
-def expect_client_component_inside_server_component_hydrated(page)
- expect(page).to have_text("Post 1")
- expect(page).to have_text("Content 1")
- expect(page).to have_button("Toggle")
-
- # Check that the client component is hydrated
- click_button "Toggle"
- expect(page).not_to have_text("Content 1")
-end
-
-# The following two tests ensure that server components can be rendered inside client components
-# and ensure that no race condition happens that make client side refetch the RSC payload
-# that is already embedded in the HTML
-# By ensuring that the client component is only hydrated after the server component is
-# rendered and its HTML is embedded in the page
-describe "Pages/async_on_server_sync_on_client_client_render", :js do
- subject(:async_component) { find_by_id("AsyncOnServerSyncOnClient-react-component-0") }
-
- let(:component_logs_tag) { "[AsyncOnServerSyncOnClient]" }
-
- before do
- # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation
- page.driver.browser.logs.get(:browser)
- end
-
- it "all components are rendered on client" do
- chunks_count = 0
- # Nothing is rendered on the server
- navigate_with_streaming("/async_on_server_sync_on_client_client_render") do |content|
- next unless content.include?("Understanding Server/Client Component Hydration Patterns")
-
- chunks_count += 1
- # This part is rendered from the rails view
- expect(content).to include("Understanding Server/Client Component Hydration Patterns")
- # remove the rails view content
- rails_view_index = content.index("Understanding Server/Client Component Hydration Patterns")
- content = content[0...rails_view_index]
-
- # This part is rendered from the server component on client
- expect(content).not_to include("Async Component 1 from Suspense Boundary1")
- expect(content).not_to include("Async Component 1 from Suspense Boundary2")
- expect(content).not_to include("Async Component 1 from Suspense Boundary3")
- end
- expect(chunks_count).to be <= 1
-
- # After client side rendering, the component should exist in the DOM
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary1")
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary2")
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary3")
-
- # Should render "Simple Component" server component
- expect(async_component).to have_text("Post 1")
- expect(async_component).to have_button("Toggle")
- end
-
- it "fetches RSC payload of the Simple Component to render it on client" do
- fetch_requests_while_streaming
-
- navigate_with_streaming "/async_on_server_sync_on_client_client_render"
- expect(async_component).to have_text("Post 1")
- expect(async_component).to have_button("Toggle")
- fetch_requests = fetch_requests_while_streaming
- expect(fetch_requests).to eq([
- { url: "/rsc_payload/SimpleComponent?props=%7B%7D" }
- ])
- end
-
- it "renders the client components on the client side in a sync manner" do
- navigate_with_streaming "/async_on_server_sync_on_client_client_render"
-
- component_logs = async_on_server_sync_on_client_client_render_logs
- # The last log happen if the test catched the re-render of the suspensed component on the client
- expect(component_logs.size).to be_between(13, 15)
-
- # To understand how these logs show that components are rendered in a sync manner,
- # check the component page in the dummy app `/async_on_server_sync_on_client_client_render`
- expect(component_logs[0...13]).to eq([
- "AsyncContent rendered",
- async_component_rendered_message(0, 0),
- async_component_rendered_message(0, 1),
- async_component_rendered_message(1, 0),
- async_component_rendered_message(2, 0),
- async_component_rendered_message(3, 0),
- async_loading_component_message(3),
- async_component_hydrated_message(0, 0),
- async_component_hydrated_message(0, 1),
- async_component_hydrated_message(1, 0),
- async_component_hydrated_message(2, 0),
- "AsyncContent has been mounted",
- async_component_rendered_message(3, 0)
- ])
- end
-
- it "hydrates the client component inside server component" do # rubocop:disable RSpec/NoExpectationExample
- navigate_with_streaming "/async_on_server_sync_on_client_client_render"
- expect_client_component_inside_server_component_hydrated(async_component)
- end
-end
-
-describe "Pages/async_on_server_sync_on_client", :js do
- subject(:async_component) { find_by_id("AsyncOnServerSyncOnClient-react-component-0") }
-
- let(:component_logs_tag) { "[AsyncOnServerSyncOnClient]" }
-
- before do
- # Clear the browser logs. so any test reading the logs will only read the logs from the current page navigation
- page.driver.browser.logs.get(:browser)
- end
-
- it "all components are rendered on server" do
- received_server_html = ""
- navigate_with_streaming("/async_on_server_sync_on_client") do |content|
- received_server_html += content
- end
- expect(received_server_html).to include("Async Component 1 from Suspense Boundary1")
- expect(received_server_html).to include("Async Component 1 from Suspense Boundary2")
- expect(received_server_html).to include("Async Component 1 from Suspense Boundary3")
- expect(received_server_html).to include("Post 1")
- expect(received_server_html).to include("Content 1")
- expect(received_server_html).to include("Toggle")
- expect(received_server_html).to include(
- "Understanding Server/Client Component Hydration Patterns"
- )
- end
-
- it "doesn't fetch the RSC payload of the server component in the page" do
- navigate_with_streaming "/async_on_server_sync_on_client"
- expect(fetch_requests_while_streaming).to eq([])
- end
-
- it "hydrates the client component inside server component" do # rubocop:disable RSpec/NoExpectationExample
- navigate_with_streaming "/async_on_server_sync_on_client"
- expect_client_component_inside_server_component_hydrated(page)
- end
-
- it "progressively renders the page content" do
- rendering_stages = []
- navigate_with_streaming "/async_on_server_sync_on_client" do |content|
- # The first stage when all components are still being rendered on the server
- if content.include?("Loading Suspense Boundary3")
- rendering_stages << 1
- expect(async_component).to have_text("Loading Suspense Boundary3")
- expect(async_component).to have_text("Loading Suspense Boundary2")
- expect(async_component).to have_text("Loading Suspense Boundary1")
-
- expect(async_component).not_to have_text("Post 1")
- expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary1")
- expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary2")
- expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary3")
- # The second stage when the Suspense Boundary3 (with 1000ms delay) is rendered on the server
- elsif content.include?("Async Component 1 from Suspense Boundary3")
- rendering_stages << 2
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary3")
- expect(async_component).not_to have_text("Post 1")
- expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary1")
- expect(async_component).not_to have_text("Async Component 1 from Suspense Boundary2")
- expect(async_component).not_to have_text("Loading Suspense Boundary3")
- # The third stage when the Suspense Boundary2 (with 3000ms delay) is rendered on the server
- elsif content.include?("Async Component 1 from Suspense Boundary2")
- rendering_stages << 3
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary3")
- expect(async_component).to have_text("Post 1")
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary1")
- expect(async_component).to have_text("Async Component 1 from Suspense Boundary2")
- expect(async_component).not_to have_text("Loading Suspense Boundary2")
-
- # Expect that client component is hydrated
- expect(async_component).to have_text("Content 1")
- expect(async_component).to have_button("Toggle")
-
- # Expect that the client component is hydrated
- click_button "Toggle"
- expect(page).not_to have_text("Content 1")
- end
- end
- expect(rendering_stages).to eq([1, 2, 3])
- end
-
- it "doesn't hydrate client components until they are rendered on the server" do
- rendering_stages = []
- component_logs = []
-
- navigate_with_streaming "/async_on_server_sync_on_client" do |content|
- component_logs += async_on_server_sync_on_client_client_render_logs
-
- # The first stage when all components are still being rendered on the server
- if content.include?("
Loading Suspense Boundary3
")
- rendering_stages << 1
- expect(component_logs).not_to include(async_component_rendered_message(0, 0))
- expect(component_logs).not_to include(async_component_rendered_message(1, 0))
- expect(component_logs).not_to include(async_component_rendered_message(2, 0))
- # The second stage when the Suspense Boundary3 (with 1000ms delay) is rendered on the server
- elsif content.include?("
Async Component 1 from Suspense Boundary3 (1000ms server side delay)
")
- rendering_stages << 2
- expect(component_logs).to include("AsyncContent rendered")
- expect(component_logs).to include("AsyncContent has been mounted")
- expect(component_logs).not_to include(async_component_rendered_message(1, 0))
- # The third stage when the Suspense Boundary2 (with 3000ms delay) is rendered on the server
- elsif content.include?("
Async Component 1 from Suspense Boundary2 (3000ms server side delay)
")
- rendering_stages << 3
- expect(component_logs).to include(async_component_rendered_message(1, 0))
- expect(component_logs).to include(async_component_rendered_message(2, 0))
- end
- end
-
- expect(rendering_stages).to eq([1, 2, 3])
- end
-
- it "hydrates the client component inside server component before the full page is loaded" do
- chunks_count = 0
- client_component_hydrated_on_chunk = nil
- component_logs = []
- navigate_with_streaming "/async_on_server_sync_on_client" do |_content|
- chunks_count += 1
- component_logs += async_on_server_sync_on_client_client_render_logs
-
- if client_component_hydrated_on_chunk.nil? && component_logs.include?(async_component_hydrated_message(3, 0))
- client_component_hydrated_on_chunk = chunks_count
- expect_client_component_inside_server_component_hydrated(async_component)
- end
- end
- expect(client_component_hydrated_on_chunk).to be < chunks_count
- end
-
- it "Server component is pre-rendered on the server and not showing loading component on the client" do
- navigate_with_streaming "/async_on_server_sync_on_client"
- component_logs = async_on_server_sync_on_client_client_render_logs
- expect(component_logs).not_to include(async_loading_component_message(3))
- end
-end
diff --git a/react_on_rails_pro/spec/dummy/client/tsconfig.json b/react_on_rails_pro/spec/dummy/tsconfig.json
similarity index 60%
rename from react_on_rails_pro/spec/dummy/client/tsconfig.json
rename to react_on_rails_pro/spec/dummy/tsconfig.json
index d728ed443..1bc5a8763 100644
--- a/react_on_rails_pro/spec/dummy/client/tsconfig.json
+++ b/react_on_rails_pro/spec/dummy/tsconfig.json
@@ -7,5 +7,11 @@
"strict": true,
"skipLibCheck": true
},
- "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"]
+ "include": [
+ "client/**/*.ts",
+ "client/**/*.tsx",
+ "client/**/*.d.ts",
+ "e2e-tests/*",
+ "./playwright.config.ts"
+ ]
}
diff --git a/react_on_rails_pro/spec/dummy/yarn.lock b/react_on_rails_pro/spec/dummy/yarn.lock
index 95c3d951e..c7d322fe2 100644
--- a/react_on_rails_pro/spec/dummy/yarn.lock
+++ b/react_on_rails_pro/spec/dummy/yarn.lock
@@ -1124,6 +1124,13 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
+"@playwright/test@^1.56.1":
+ version "1.56.1"
+ resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.56.1.tgz#6e3bf3d0c90c5cf94bf64bdb56fd15a805c8bd3f"
+ integrity sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==
+ dependencies:
+ playwright "1.56.1"
+
"@pmmmwh/react-refresh-webpack-plugin@0.5.3":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.3.tgz#b8f0e035f6df71b5c4126cb98de29f65188b9e7b"
@@ -1387,6 +1394,13 @@
dependencies:
undici-types "~5.26.4"
+"@types/node@^24.8.1":
+ version "24.8.1"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-24.8.1.tgz#74c8ae00b045a0a351f2837ec00f25dfed0053be"
+ integrity sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==
+ dependencies:
+ undici-types "~7.14.0"
+
"@types/parse-json@^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239"
@@ -3409,6 +3423,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+fsevents@2.3.2:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+ integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
fsevents@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@@ -4941,6 +4960,20 @@ pkg-up@^3.1.0:
dependencies:
find-up "^3.0.0"
+playwright-core@1.56.1:
+ version "1.56.1"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.56.1.tgz#24a66481e5cd33a045632230aa2c4f0cb6b1db3d"
+ integrity sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==
+
+playwright@1.56.1:
+ version "1.56.1"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.56.1.tgz#62e3b99ddebed0d475e5936a152c88e68be55fbf"
+ integrity sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==
+ dependencies:
+ playwright-core "1.56.1"
+ optionalDependencies:
+ fsevents "2.3.2"
+
postcss-calc@^8.2.3:
version "8.2.4"
resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5"
@@ -6513,6 +6546,11 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+undici-types@~7.14.0:
+ version "7.14.0"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.14.0.tgz#4c037b32ca4d7d62fae042174604341588bc0840"
+ integrity sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==
+
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc"
diff --git a/react_on_rails_pro/yarn.lock b/react_on_rails_pro/yarn.lock
index 73fd9af02..57f7780ab 100644
--- a/react_on_rails_pro/yarn.lock
+++ b/react_on_rails_pro/yarn.lock
@@ -1597,32 +1597,32 @@
"@pnpm/network.ca-file" "^1.0.1"
config-chain "^1.1.11"
-"@redis/bloom@5.0.1":
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-5.0.1.tgz#5f4c6bb2cce3908a4c3d246c69187321db9e1818"
- integrity sha512-F7L+rnuJvq/upKaVoEgsf8VT7g5pLQYWRqSUOV3uO4vpVtARzSKJ7CLyJjVsQS+wZVCGxsLMh8DwAIDcny1B+g==
+"@redis/bloom@5.8.3":
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-5.8.3.tgz#c0f04cba102eeb3a68792a599f7c320915eba366"
+ integrity sha512-1eldTzHvdW3Oi0TReb8m1yiFt8ZwyF6rv1NpZyG5R4TpCwuAdKQetBKoCw7D96tNFgsVVd6eL+NaGZZCqhRg4g==
-"@redis/client@5.0.1":
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/@redis/client/-/client-5.0.1.tgz#d39d9c0114b865e2186e46b6d891701f46b293ad"
- integrity sha512-k0EJvlMGEyBqUD3orKe0UMZ66fPtfwqPIr+ZSd853sXj2EyhNtPXSx+J6sENXJNgAlEBhvD+57Dwt0qTisKB0A==
+"@redis/client@5.8.3":
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/@redis/client/-/client-5.8.3.tgz#186ebdd30ff874eae35b8d0ea3151137c22802be"
+ integrity sha512-MZVUE+l7LmMIYlIjubPosruJ9ltSLGFmJqsXApTqPLyHLjsJUSAbAJb/A3N34fEqean4ddiDkdWzNu4ZKPvRUg==
dependencies:
cluster-key-slot "1.1.2"
-"@redis/json@5.0.1":
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/@redis/json/-/json-5.0.1.tgz#7eef09a2fac9e6ca6188ec34f9d313d4617f5b5b"
- integrity sha512-t94HOTk5myfhvaHZzlUzk2hoUvH2jsjftcnMgJWuHL/pzjAJQoZDCUJzjkoXIUjWXuyJixTguaaDyOZWwqH2Kg==
+"@redis/json@5.8.3":
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/@redis/json/-/json-5.8.3.tgz#f4efab4245b9f5c1d4ab80ec6f3c13aecb663c1e"
+ integrity sha512-DRR09fy/u8gynHGJ4gzXYeM7D8nlS6EMv5o+h20ndTJiAc7RGR01fdk2FNjnn1Nz5PjgGGownF+s72bYG4nZKQ==
-"@redis/search@5.0.1":
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/@redis/search/-/search-5.0.1.tgz#805038a010b3d58765c65374d1a730247dfd2a5f"
- integrity sha512-wipK6ZptY7K68B7YLVhP5I/wYCDUU+mDJMyJiUcQLuOs7/eKOBc8lTXKUSssor8QnzZSPy4A5ulcC5PZY22Zgw==
+"@redis/search@5.8.3":
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/@redis/search/-/search-5.8.3.tgz#eb1159d1f6576244f47d779535515e83561ec1a4"
+ integrity sha512-EMIvEeGRR2I0BJEz4PV88DyCuPmMT1rDtznlsHY3cKSDcc9vj0Q411jUnX0iU2vVowUgWn/cpySKjpXdZ8m+5g==
-"@redis/time-series@5.0.1":
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-5.0.1.tgz#b63654dad199fcbcd8315c27d6ae46db9120c211"
- integrity sha512-k6PgbrakhnohsEWEAdQZYt3e5vSKoIzpKvgQt8//lnWLrTZx+c3ed2sj0+pKIF4FvnSeuXLo4bBWcH0Z7Urg1A==
+"@redis/time-series@5.8.3":
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-5.8.3.tgz#d20158dceba05fd456f2abc58b05fe16c736d18e"
+ integrity sha512-5Jwy3ilsUYQjzpE7WZ1lEeG1RkqQ5kHtwV1p8yxXHSEmyUbC/T/AVgyjMcm52Olj/Ov/mhDKjx6ndYUi14bXsw==
"@rtsao/scc@^1.1.0":
version "1.1.0"
@@ -6988,15 +6988,15 @@ rechoir@^0.6.2:
resolve "^1.1.6"
redis@^5.0.1:
- version "5.0.1"
- resolved "https://registry.yarnpkg.com/redis/-/redis-5.0.1.tgz#2eda8388e1350638616fa4b2dc4a9f5dbdfa1911"
- integrity sha512-J8nqUjrfSq0E8NQkcHDZ4HdEQk5RMYjP3jZq02PE+ERiRxolbDNxPaTT4xh6tdrme+lJ86Goje9yMt9uzh23hQ==
- dependencies:
- "@redis/bloom" "5.0.1"
- "@redis/client" "5.0.1"
- "@redis/json" "5.0.1"
- "@redis/search" "5.0.1"
- "@redis/time-series" "5.0.1"
+ version "5.8.3"
+ resolved "https://registry.yarnpkg.com/redis/-/redis-5.8.3.tgz#c65d52ff9099579e278bf8ce100efbadafe5580a"
+ integrity sha512-MfSrfV6+tEfTw8c4W0yFp6XWX8Il4laGU7Bx4kvW4uiYM1AuZ3KGqEGt1LdQHeD1nEyLpIWetZ/SpY3kkbgrYw==
+ dependencies:
+ "@redis/bloom" "5.8.3"
+ "@redis/client" "5.8.3"
+ "@redis/json" "5.8.3"
+ "@redis/search" "5.8.3"
+ "@redis/time-series" "5.8.3"
reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9:
version "1.0.10"