diff --git a/.circleci/config.yml b/.circleci/config.yml index 40dc6270a..0b641949c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -338,11 +338,63 @@ jobs: path: ~/rspec - store_artifacts: path: react_on_rails_pro/spec/dummy/tmp/screenshots + - store_artifacts: + path: react_on_rails_pro/spec/dummy/tmp/capybara - store_artifacts: path: react_on_rails_pro/spec/dummy/log/test.log - store_artifacts: path: react_on_rails_pro/spec/dummy/yarn-error.log + # TODO: DRY with previous job + dummy-app-node-renderer-e2-tests: + docker: + - image: *docker_image + - image: cimg/redis:6.2.6 + steps: + - checkout + - run: *print-system-info + - restore_cache: *restore-package-gem-cache + - restore_cache: *restore-package-node-modules-cache + - restore_cache: *restore-dummy-app-node-modules-cache + - restore_cache: *restore-dummy-app-gem-cache + - run: rm -rf react_on_rails_pro/spec/dummy/public/webpack + - run: rm -rf react_on_rails_pro/spec/dummy/ssr-generated + - restore_cache: *restore-dummy-app-webpack-bundle-cache + - run: *install-dummy-app-ruby-gems + - run: *install-package-node-modules + - run: *install-latest-chrome + - run: *install-dummy-app-node-modules + - run: + name: Generate file-system based entrypoints (Pro) + working_directory: react_on_rails_pro + command: cd spec/dummy && bundle exec rake react_on_rails:generate_packs + - run: + name: Run Pro Node renderer in a background + working_directory: react_on_rails_pro + command: cd spec/dummy && yarn run node-renderer + background: true + - run: + name: run rails server in background (Pro dummy app) + working_directory: react_on_rails_pro + command: cd spec/dummy && RAILS_ENV=test rails server + background: true + - run: + name: wait for rails server to start + command: | + while ! curl -s http://localhost:3000 > /dev/null; do sleep 1; done + - run: + name: install playwright dependencies + working_directory: react_on_rails_pro/spec/dummy + command: yarn playwright install --with-deps + - run: + name: Run playwright tests (Pro dummy app) + working_directory: react_on_rails_pro/spec/dummy + command: yarn e2e-test + - store_test_results: + path: react_on_rails_pro/spec/dummy/test-results/results.xml + - store_artifacts: + path: react_on_rails_pro/spec/dummy/playwright-report + workflows: version: 2 build-and-test: @@ -374,3 +426,7 @@ workflows: requires: - install-package-ruby-gems - build-dummy-app-webpack-test-bundles + - dummy-app-node-renderer-e2-tests: + requires: + - install-package-ruby-gems + - build-dummy-app-webpack-test-bundles diff --git a/packages/react-on-rails-pro/src/RSCRoute.tsx b/packages/react-on-rails-pro/src/RSCRoute.tsx index e2d614567..1c514b8cf 100644 --- a/packages/react-on-rails-pro/src/RSCRoute.tsx +++ b/packages/react-on-rails-pro/src/RSCRoute.tsx @@ -14,6 +14,8 @@ /// +'use client'; + import * as React from 'react'; import { useRSC } from './RSCProvider.tsx'; import { ServerComponentFetchError } from './ServerComponentFetchError.ts'; diff --git a/react_on_rails_pro/eslint.config.mjs b/react_on_rails_pro/eslint.config.mjs index 7dc0c1515..cdfff0838 100644 --- a/react_on_rails_pro/eslint.config.mjs +++ b/react_on_rails_pro/eslint.config.mjs @@ -196,6 +196,24 @@ export default defineConfig([ ], }, }, + { + files: ['spec/dummy/e2e-tests/*'], + + rules: { + 'no-empty-pattern': [ + 'error', + { + allowObjectPatternsAsParameters: true, + }, + ], + }, + }, + { + files: ['spec/dummy/e2e-tests/*'], + rules: { + 'react-hooks/rules-of-hooks': ['off'], + }, + }, // must be the last config in the array // https://github.com/prettier/eslint-plugin-prettier?tab=readme-ov-file#configuration-new-eslintconfigjs prettierRecommended, diff --git a/react_on_rails_pro/package-scripts.yml b/react_on_rails_pro/package-scripts.yml index 8072f5fac..f9971f10a 100644 --- a/react_on_rails_pro/package-scripts.yml +++ b/react_on_rails_pro/package-scripts.yml @@ -15,7 +15,7 @@ scripts: script: nps lint && nps format.listDifferent && nps test && nps check-typescript check-typescript: description: Check for TypeScript errors - script: nps "build --noEmit" && tsc --project packages/node-renderer/tests && cd spec/dummy && yarn run tsc -p client/tsconfig.json --noEmit + script: nps "build --noEmit" && tsc --project packages/node-renderer/tests && cd spec/dummy && yarn run tsc -p ./tsconfig.json --noEmit fix: description: Run all code fixes before committing script: nps eslint.fix && nps format diff --git a/react_on_rails_pro/spec/dummy/.gitignore b/react_on_rails_pro/spec/dummy/.gitignore index 6fc839654..8ddc54955 100644 --- a/react_on_rails_pro/spec/dummy/.gitignore +++ b/react_on_rails_pro/spec/dummy/.gitignore @@ -1,2 +1,9 @@ # React on Rails Pro license file config/react_on_rails_pro_license.key + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage0.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage0.aria.yml new file mode 100644 index 000000000..6f3cf23cb --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage0.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - paragraph: Waiting for the key "Item1" + - paragraph: Waiting for the key "Item2" + - paragraph: Waiting for the key "Item3" + - paragraph: Waiting for the key "Item4" + - paragraph: Waiting for the key "Item5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage1.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage1.aria.yml new file mode 100644 index 000000000..abca25ed2 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage1.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - listitem: "Value of \"Item1\": Incremental Value1" + - paragraph: Waiting for the key "Item2" + - paragraph: Waiting for the key "Item3" + - paragraph: Waiting for the key "Item4" + - paragraph: Waiting for the key "Item5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage2.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage2.aria.yml new file mode 100644 index 000000000..93e320d66 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage2.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - listitem: "Value of \"Item1\": Incremental Value1" + - paragraph: Waiting for the key "Item2" + - paragraph: Waiting for the key "Item3" + - listitem: "Value of \"Item4\": Incremental Value4" + - paragraph: Waiting for the key "Item5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage3.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage3.aria.yml new file mode 100644 index 000000000..f9925ea3b --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage3.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - listitem: "Value of \"Item1\": Incremental Value1" + - listitem: "Value of \"Item2\": Incremental Value2" + - paragraph: Waiting for the key "Item3" + - listitem: "Value of \"Item4\": Incremental Value4" + - paragraph: Waiting for the key "Item5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage4.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage4.aria.yml new file mode 100644 index 000000000..521d0e661 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage4.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - listitem: "Value of \"Item1\": Incremental Value1" + - listitem: "Value of \"Item2\": Incremental Value2" + - listitem: "Value of \"Item3\": Incremental Value3" + - listitem: "Value of \"Item4\": Incremental Value4" + - paragraph: Waiting for the key "Item5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage5.aria.yml b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage5.aria.yml new file mode 100644 index 000000000..5d1544f60 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/__snapshots__/streaming.spec.ts/stage5.aria.yml @@ -0,0 +1,9 @@ +- main: + - heading "A list of items received from Redis:" [level=1] + - button "Hide Redis Items" + - list: + - listitem: "Value of \"Item1\": Incremental Value1" + - listitem: "Value of \"Item2\": Incremental Value2" + - listitem: "Value of \"Item3\": Incremental Value3" + - listitem: "Value of \"Item4\": Incremental Value4" + - listitem: "Value of \"Item5\": Incremental Value5" \ No newline at end of file diff --git a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb index ec668f4f0..f2f6d309e 100644 --- a/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb +++ b/react_on_rails_pro/spec/dummy/app/controllers/pages_controller.rb @@ -69,6 +69,42 @@ def rsc_posts_page_over_redis raise "Redis thread timed out" end + def redis_receiver + @request_id = SecureRandom.uuid + + redis_thread = Thread.new do + redis = ::Redis.new + 5.times do |index| + sleep 1 + redis.xadd("stream:#{@request_id}", { ":Item#{index}" => "Value of Item#{index + 1}".to_json }) + end + rescue StandardError => e + Rails.logger.error "Error writing Items to Redis: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise e + ensure + begin + redis&.close + rescue StandardError => close_err + Rails.logger.warn "Failed to close Redis: #{close_err.message}" + end + end + + stream_view_containing_react_components(template: "/pages/redis_receiver") + + return if redis_thread.join(10) + + Rails.logger.error "Redis thread timed out" + raise "Redis thread timed out" + end + + def redis_receiver_for_testing + @request_id = params[:request_id] + raise "request_id is required at the url" if @request_id.blank? + + stream_view_containing_react_components(template: "/pages/redis_receiver") + end + def async_on_server_sync_on_client @render_on_server = true stream_view_containing_react_components(template: "/pages/async_on_server_sync_on_client") diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/redis_receiver.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/redis_receiver.html.erb new file mode 100644 index 000000000..eb4dc4196 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/app/views/pages/redis_receiver.html.erb @@ -0,0 +1,8 @@ +<%= stream_react_component("RedisReceiver", + props: { requestId: @request_id, asyncToggleContainer: params[:async_toggle_container] }, + prerender: true, + trace: true, + id: "RedisReceiver-react-component-0") %> +
+ +

React Rails Server Streaming Redis Receiver

diff --git a/react_on_rails_pro/spec/dummy/app/views/pages/server_router.html.erb b/react_on_rails_pro/spec/dummy/app/views/pages/server_router.html.erb index c5b3cb405..91347c221 100644 --- a/react_on_rails_pro/spec/dummy/app/views/pages/server_router.html.erb +++ b/react_on_rails_pro/spec/dummy/app/views/pages/server_router.html.erb @@ -1,5 +1,9 @@ <%= stream_react_component("ServerComponentRouter", - props: @app_props_server_render.merge(artificialDelay: params[:artificial_delay] || 0, postsCount: params[:posts_count] || 2), + props: @app_props_server_render.merge( + artificialDelay: params[:artificial_delay] || 0, + postsCount: params[:posts_count] || 2, + requestId: params[:request_id], + ), trace: true, id: "ServerComponentRouter-react-component-0") %>
diff --git a/react_on_rails_pro/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx b/react_on_rails_pro/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx index ce68fb919..174866ce1 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/RSCPostsPage/ToggleContainer.jsx @@ -10,7 +10,12 @@ const ToggleContainer = ({ children, childrenTitle }) => { return (
- {isVisible && children} diff --git a/react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx b/react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx index 35c5f8016..3c36bdd1b 100644 --- a/react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx +++ b/react_on_rails_pro/spec/dummy/client/app/components/ServerComponentRouter.tsx @@ -41,6 +41,9 @@ export default function App({ basePath = '/server_router', ...props }: { basePat Server Component with visible streaming behavior +
  • + Redis Receiver For Testing +
  • Server Component with Retry
  • @@ -71,6 +74,10 @@ export default function App({ basePath = '/server_router', ...props }: { basePat /> } /> + } + /> } diff --git a/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx new file mode 100644 index 000000000..f871a73c3 --- /dev/null +++ b/react_on_rails_pro/spec/dummy/client/app/ror-auto-load-components/RedisReceiver.jsx @@ -0,0 +1,63 @@ +import React, { Suspense } from 'react'; +import ToggleContainer from '../components/RSCPostsPage/ToggleContainerForServerComponents'; +import { listenToRequestData } from '../utils/redisReceiver'; +import { ErrorBoundary } from '../components/ErrorBoundary'; + +const RedisItem = async ({ getValue, itemIndex }) => { + const value = await getValue(`Item${itemIndex}`); + return ( +
  • + Value of "Item{itemIndex + 1}": {value} +
  • + ); +}; + +const RedisItemWithWrapper = ({ getValue, itemIndex }) => ( +
    + + Waiting for the key "Item{itemIndex + 1}" +

    + } + > + +
    +
    +); + +// Convert it to async component and make tests control when it's rendered +// To test the page behavior when a client component is rendered asynchronously at the page +const AsyncToggleContainer = async ({ children, childrenTitle, getValue }) => { + await getValue('ToggleContainer'); + return {children}; +}; + +const RedisReceiver = ({ requestId, asyncToggleContainer }, railsContext) => { + const { getValue, close } = listenToRequestData(requestId); + + if ('addPostSSRHook' in railsContext) { + railsContext.addPostSSRHook(close); + } + + const UsedToggleContainer = asyncToggleContainer ? AsyncToggleContainer : ToggleContainer; + + return () => ( + +
    +

    A list of items received from Redis:

    + Loading ToggleContainer
    }> + +
      + {[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"