From 5c52737108188ba708a73b5d241e4f0c57a7e63c Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 7 May 2026 12:18:50 +0200 Subject: [PATCH 1/5] feat: deferred hydration --- .changeset/deferred-hydration-start.md | 16 + benchmarks/bundle-size/README.md | 1 + .../src/router.tsx | 9 + .../src/routes/__root.tsx | 24 + .../src/routes/about.tsx | 15 + .../src/routes/index.tsx | 15 + .../vite.config.ts | 7 + .../src/router.tsx | 9 + .../src/routes/__root.tsx | 24 + .../src/routes/about.tsx | 15 + .../src/routes/index.tsx | 15 + .../vite.config.ts | 7 + docs/start/config.json | 8 + .../react/guide/deferred-hydration.md | 319 ++++++ .../solid/guide/deferred-hydration.md | 12 + e2e/react-start/deferred-hydration/.gitignore | 15 + .../deferred-hydration/package.json | 56 ++ .../deferred-hydration/playwright.config.ts | 41 + .../deferred-hydration/rsbuild.config.ts | 14 + e2e/react-start/deferred-hydration/server.js | 99 ++ .../deferred-hydration/src/routeTree.gen.ts | 122 +++ .../deferred-hydration/src/router.tsx | 9 + .../deferred-hydration/src/routes/__root.tsx | 163 ++++ .../src/routes/components.tsx | 179 ++++ .../deferred-hydration/src/routes/css.tsx | 57 ++ .../src/routes/css/deferred-only.module.css | 15 + .../src/routes/css/outer.module.css | 9 + .../src/routes/css/shared.module.css | 4 + .../src/routes/imported.tsx | 15 + .../deferred-hydration/src/routes/index.tsx | 21 + .../src/shared/ImportedHydrateWidget.tsx | 33 + .../tests/hydration.spec.ts | 519 ++++++++++ .../deferred-hydration/tsconfig.json | 21 + .../deferred-hydration/vite.config.ts | 14 + .../rsc-deferred-hydration/.gitignore | 5 + .../rsc-deferred-hydration/package.json | 45 + .../playwright.config.ts | 35 + .../rsc-deferred-hydration/server.js | 47 + .../components/CssHydrateIsland.module.css | 42 + .../src/components/CssHydrateIsland.tsx | 45 + .../src/components/DeferredHydrateIsland.tsx | 67 ++ .../src/routeTree.gen.ts | 122 +++ .../rsc-deferred-hydration/src/router.tsx | 9 + .../src/routes/__root.tsx | 122 +++ .../src/routes/composite.tsx | 24 + .../rsc-deferred-hydration/src/routes/css.tsx | 14 + .../src/routes/index.tsx | 35 + .../src/routes/server-client.tsx | 14 + .../src/server/serverHydrateComponents.tsx | 35 + .../src/server/serverHydrateContent.tsx | 63 ++ .../tests/hydration.spec.ts | 82 ++ .../tests/setup/global.setup.ts | 28 + .../tests/setup/global.teardown.ts | 6 + .../rsc-deferred-hydration/tsconfig.json | 21 + .../rsc-deferred-hydration/vite.config.ts | 20 + .../server-routes/src/routeTree.gen.ts | 98 +- e2e/solid-start/deferred-hydration/.gitignore | 15 + .../deferred-hydration/package.json | 54 ++ .../deferred-hydration/playwright.config.ts | 41 + .../deferred-hydration/rsbuild.config.ts | 21 + e2e/solid-start/deferred-hydration/server.js | 99 ++ .../deferred-hydration/src/routeTree.gen.ts | 122 +++ .../deferred-hydration/src/router.tsx | 9 + .../deferred-hydration/src/routes/__root.tsx | 92 ++ .../src/routes/components.tsx | 171 ++++ .../deferred-hydration/src/routes/css.tsx | 57 ++ .../src/routes/css/deferred-only.module.css | 15 + .../src/routes/css/outer.module.css | 9 + .../src/routes/css/shared.module.css | 4 + .../src/routes/imported.tsx | 15 + .../deferred-hydration/src/routes/index.tsx | 21 + .../src/shared/ImportedHydrateWidget.tsx | 34 + .../tests/hydration.spec.ts | 519 ++++++++++ .../deferred-hydration/tsconfig.json | 23 + .../deferred-hydration/vite.config.ts | 14 + packages/react-router/src/index.tsx | 1 + packages/react-start-client/package.json | 6 + .../react-start-client/src/GenericHydrate.tsx | 292 ++++++ packages/react-start-client/src/Hydrate.tsx | 53 + packages/react-start-client/src/hydration.ts | 16 + .../src/hydration/generic.ts | 38 + .../react-start-client/src/hydration/idle.ts | 15 + .../react-start-client/src/hydration/load.tsx | 69 ++ .../src/hydration/never.tsx | 99 ++ .../src/hydration/visible.tsx | 185 ++++ packages/react-start-client/src/index.tsx | 13 + .../src/lazyHydratedComponent.tsx | 9 + .../src/tests/Hydrate.test-d.tsx | 85 ++ .../src/tests/Hydrate.test.tsx | 396 ++++++++ packages/react-start-client/vite.config.ts | 2 +- packages/react-start/package.json | 6 + packages/react-start/src/hydration.ts | 18 + packages/react-start/src/index.ts | 11 + packages/react-start/vite.config.ts | 1 + packages/router-core/src/index.ts | 1 + packages/router-core/src/ssr/ssr-server.ts | 20 +- .../src/core/code-splitter/compilers.ts | 462 +-------- .../src/core/code-splitter/plugins.ts | 3 + packages/router-plugin/src/core/config.ts | 7 + .../src/core/router-code-splitter-plugin.ts | 16 +- packages/router-plugin/src/index.ts | 5 + .../router-plugin/tests/code-splitter.test.ts | 2 +- packages/router-utils/src/compiler-helpers.ts | 452 +++++++++ packages/router-utils/src/index.ts | 19 + .../src}/path-ids.ts | 0 packages/solid-router/src/ClientOnly.tsx | 7 +- packages/solid-start-client/package.json | 6 + .../solid-start-client/src/GenericHydrate.tsx | 266 +++++ packages/solid-start-client/src/Hydrate.tsx | 51 + packages/solid-start-client/src/hydration.ts | 14 + .../src/hydration/generic.ts | 36 + .../solid-start-client/src/hydration/idle.ts | 13 + .../solid-start-client/src/hydration/load.tsx | 79 ++ .../solid-start-client/src/hydration/never.ts | 10 + .../src/hydration/visible.tsx | 180 ++++ packages/solid-start-client/src/index.tsx | 11 + .../src/lazyHydratedComponent.tsx | 9 + .../src/tests/Hydrate.test-d.tsx | 85 ++ packages/solid-start-client/vite.config.ts | 7 +- packages/solid-start/package.json | 6 + packages/solid-start/src/hydration.ts | 18 + packages/solid-start/src/index.ts | 10 + packages/solid-start/vite.config.ts | 1 + packages/start-client-core/package.json | 21 + packages/start-client-core/src/hydration.ts | 40 + .../src/hydration/condition.ts | 23 + .../src/hydration/constants.ts | 4 + .../start-client-core/src/hydration/idle.ts | 42 + .../src/hydration/interaction.ts | 295 ++++++ .../start-client-core/src/hydration/load.ts | 17 + .../start-client-core/src/hydration/media.ts | 28 + .../start-client-core/src/hydration/never.ts | 16 + .../src/hydration/runtime.ts | 137 +++ .../start-client-core/src/hydration/types.ts | 63 ++ .../src/hydration/visible.ts | 225 +++++ packages/start-client-core/vite.config.ts | 3 + .../src/hydrate-when-transform.ts | 916 ++++++++++++++++++ .../src/hydration-constants.ts | 1 + .../src/rsbuild/normalized-client-build.ts | 36 + .../src/rsbuild/start-compiler-host.ts | 64 +- .../src/start-compiler/compiler.ts | 805 ++++++++------- .../src/start-compiler/config.ts | 9 + .../src/start-compiler/host.ts | 46 + .../start-manifest-plugin/manifestBuilder.ts | 61 ++ packages/start-plugin-core/src/types.ts | 32 + .../src/vite/start-compiler-plugin/plugin.ts | 72 +- .../normalized-client-build.ts | 37 + .../tests/hydrate-when-transform.test.ts | 607 ++++++++++++ .../error-files/hydrateWhenFunctionChild.tsx | 6 + .../error-files/hydrateWhenHookCall.tsx | 14 + .../error-files/hydrateWhenSuperCapture.tsx | 16 + .../error-files/hydrateWhenThisCapture.tsx | 14 + .../tests/hydrateWhen/hydrateWhen.test.ts | 233 +++++ .../snapshots/client/hydrateWhenBasic.tsx | 20 + .../snapshots/client/hydrateWhenMultiple.tsx | 31 + .../snapshots/client/hydrateWhenNested.tsx | 27 + .../snapshots/client/hydrateWhenNever.tsx | 13 + .../snapshots/client/hydrateWhenNoImport.tsx | 1 + .../client/hydrateWhenNotFromTanstack.tsx | 1 + .../client/hydrateWhenObjectFallback.tsx | 34 + .../snapshots/client/hydrateWhenRenamed.tsx | 15 + .../client/hydrateWhenSplitFalse.tsx | 1 + .../hydrateWhenSplitFalseFunctionChild.tsx | 1 + .../client/hydrateWhenWrongImportName.tsx | 1 + .../snapshots/server/hydrateWhenBasic.tsx | 17 + .../snapshots/server/hydrateWhenMultiple.tsx | 24 + .../snapshots/server/hydrateWhenNested.tsx | 25 + .../snapshots/server/hydrateWhenNever.tsx | 10 + .../snapshots/server/hydrateWhenNoImport.tsx | 1 + .../server/hydrateWhenNotFromTanstack.tsx | 1 + .../server/hydrateWhenObjectFallback.tsx | 25 + .../snapshots/server/hydrateWhenRenamed.tsx | 12 + .../server/hydrateWhenSplitFalse.tsx | 10 + .../hydrateWhenSplitFalseFunctionChild.tsx | 1 + .../server/hydrateWhenWrongImportName.tsx | 1 + ...hydrateWhenNested.tsx.Hydrate_0.client.tsx | 19 + .../hydrateWhenNested.tsx.Hydrate_0.tsx | 16 + .../hydrateWhenNested.tsx.Hydrate_2.tsx | 8 + .../test-files/hydrateWhenBasic.tsx | 20 + .../test-files/hydrateWhenMultiple.tsx | 30 + .../test-files/hydrateWhenNested.tsx | 32 + .../test-files/hydrateWhenNever.tsx | 14 + .../test-files/hydrateWhenNoImport.tsx | 7 + .../test-files/hydrateWhenNotFromTanstack.tsx | 10 + .../test-files/hydrateWhenObjectFallback.tsx | 35 + .../test-files/hydrateWhenRenamed.tsx | 14 + .../test-files/hydrateWhenSplitFalse.tsx | 16 + .../hydrateWhenSplitFalseFunctionChild.tsx | 10 + .../test-files/hydrateWhenWrongImportName.tsx | 9 + .../manifestBuilder.test.ts | 171 ++++ .../tests/vite-start-compiler-plugin.test.ts | 48 + packages/start-plugin-core/tsconfig.json | 6 +- .../src/createStartHandler.ts | 20 +- .../tests/transformAssets.test.ts | 1 + pnpm-lock.yaml | 365 ++++--- scripts/benchmarks/bundle-size/measure.mjs | 14 + 196 files changed, 11373 insertions(+), 974 deletions(-) create mode 100644 .changeset/deferred-hydration-start.md create mode 100644 benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx create mode 100644 benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx create mode 100644 benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/about.tsx create mode 100644 benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx create mode 100644 benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts create mode 100644 benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx create mode 100644 benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx create mode 100644 benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/about.tsx create mode 100644 benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx create mode 100644 benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts create mode 100644 docs/start/framework/react/guide/deferred-hydration.md create mode 100644 docs/start/framework/solid/guide/deferred-hydration.md create mode 100644 e2e/react-start/deferred-hydration/.gitignore create mode 100644 e2e/react-start/deferred-hydration/package.json create mode 100644 e2e/react-start/deferred-hydration/playwright.config.ts create mode 100644 e2e/react-start/deferred-hydration/rsbuild.config.ts create mode 100644 e2e/react-start/deferred-hydration/server.js create mode 100644 e2e/react-start/deferred-hydration/src/routeTree.gen.ts create mode 100644 e2e/react-start/deferred-hydration/src/router.tsx create mode 100644 e2e/react-start/deferred-hydration/src/routes/__root.tsx create mode 100644 e2e/react-start/deferred-hydration/src/routes/components.tsx create mode 100644 e2e/react-start/deferred-hydration/src/routes/css.tsx create mode 100644 e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css create mode 100644 e2e/react-start/deferred-hydration/src/routes/css/outer.module.css create mode 100644 e2e/react-start/deferred-hydration/src/routes/css/shared.module.css create mode 100644 e2e/react-start/deferred-hydration/src/routes/imported.tsx create mode 100644 e2e/react-start/deferred-hydration/src/routes/index.tsx create mode 100644 e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx create mode 100644 e2e/react-start/deferred-hydration/tests/hydration.spec.ts create mode 100644 e2e/react-start/deferred-hydration/tsconfig.json create mode 100644 e2e/react-start/deferred-hydration/vite.config.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/.gitignore create mode 100644 e2e/react-start/rsc-deferred-hydration/package.json create mode 100644 e2e/react-start/rsc-deferred-hydration/playwright.config.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/server.js create mode 100644 e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css create mode 100644 e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/src/router.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx create mode 100644 e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts create mode 100644 e2e/react-start/rsc-deferred-hydration/tsconfig.json create mode 100644 e2e/react-start/rsc-deferred-hydration/vite.config.ts create mode 100644 e2e/solid-start/deferred-hydration/.gitignore create mode 100644 e2e/solid-start/deferred-hydration/package.json create mode 100644 e2e/solid-start/deferred-hydration/playwright.config.ts create mode 100644 e2e/solid-start/deferred-hydration/rsbuild.config.ts create mode 100644 e2e/solid-start/deferred-hydration/server.js create mode 100644 e2e/solid-start/deferred-hydration/src/routeTree.gen.ts create mode 100644 e2e/solid-start/deferred-hydration/src/router.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/routes/__root.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/routes/components.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/routes/css.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css create mode 100644 e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css create mode 100644 e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css create mode 100644 e2e/solid-start/deferred-hydration/src/routes/imported.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/routes/index.tsx create mode 100644 e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx create mode 100644 e2e/solid-start/deferred-hydration/tests/hydration.spec.ts create mode 100644 e2e/solid-start/deferred-hydration/tsconfig.json create mode 100644 e2e/solid-start/deferred-hydration/vite.config.ts create mode 100644 packages/react-start-client/src/GenericHydrate.tsx create mode 100644 packages/react-start-client/src/Hydrate.tsx create mode 100644 packages/react-start-client/src/hydration.ts create mode 100644 packages/react-start-client/src/hydration/generic.ts create mode 100644 packages/react-start-client/src/hydration/idle.ts create mode 100644 packages/react-start-client/src/hydration/load.tsx create mode 100644 packages/react-start-client/src/hydration/never.tsx create mode 100644 packages/react-start-client/src/hydration/visible.tsx create mode 100644 packages/react-start-client/src/lazyHydratedComponent.tsx create mode 100644 packages/react-start-client/src/tests/Hydrate.test-d.tsx create mode 100644 packages/react-start-client/src/tests/Hydrate.test.tsx create mode 100644 packages/react-start/src/hydration.ts create mode 100644 packages/router-utils/src/compiler-helpers.ts rename packages/{router-plugin/src/core/code-splitter => router-utils/src}/path-ids.ts (100%) create mode 100644 packages/solid-start-client/src/GenericHydrate.tsx create mode 100644 packages/solid-start-client/src/Hydrate.tsx create mode 100644 packages/solid-start-client/src/hydration.ts create mode 100644 packages/solid-start-client/src/hydration/generic.ts create mode 100644 packages/solid-start-client/src/hydration/idle.ts create mode 100644 packages/solid-start-client/src/hydration/load.tsx create mode 100644 packages/solid-start-client/src/hydration/never.ts create mode 100644 packages/solid-start-client/src/hydration/visible.tsx create mode 100644 packages/solid-start-client/src/lazyHydratedComponent.tsx create mode 100644 packages/solid-start-client/src/tests/Hydrate.test-d.tsx create mode 100644 packages/solid-start/src/hydration.ts create mode 100644 packages/start-client-core/src/hydration.ts create mode 100644 packages/start-client-core/src/hydration/condition.ts create mode 100644 packages/start-client-core/src/hydration/constants.ts create mode 100644 packages/start-client-core/src/hydration/idle.ts create mode 100644 packages/start-client-core/src/hydration/interaction.ts create mode 100644 packages/start-client-core/src/hydration/load.ts create mode 100644 packages/start-client-core/src/hydration/media.ts create mode 100644 packages/start-client-core/src/hydration/never.ts create mode 100644 packages/start-client-core/src/hydration/runtime.ts create mode 100644 packages/start-client-core/src/hydration/types.ts create mode 100644 packages/start-client-core/src/hydration/visible.ts create mode 100644 packages/start-plugin-core/src/hydrate-when-transform.ts create mode 100644 packages/start-plugin-core/src/hydration-constants.ts create mode 100644 packages/start-plugin-core/tests/hydrate-when-transform.test.ts create mode 100644 packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenFunctionChild.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenHookCall.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenSuperCapture.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenThisCapture.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNoImport.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNotFromTanstack.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalse.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalseFunctionChild.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenWrongImportName.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenBasic.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenMultiple.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenNested.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenNever.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenNoImport.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenNotFromTanstack.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenObjectFallback.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenRenamed.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenSplitFalse.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenSplitFalseFunctionChild.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenWrongImportName.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.Hydrate_0.client.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.Hydrate_0.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/snapshots/virtual/hydrateWhenNested.tsx.Hydrate_2.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenBasic.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenMultiple.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNested.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNever.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNoImport.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNotFromTanstack.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenObjectFallback.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenRenamed.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenSplitFalse.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenSplitFalseFunctionChild.tsx create mode 100644 packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenWrongImportName.tsx diff --git a/.changeset/deferred-hydration-start.md b/.changeset/deferred-hydration-start.md new file mode 100644 index 00000000000..90d76803998 --- /dev/null +++ b/.changeset/deferred-hydration-start.md @@ -0,0 +1,16 @@ +--- +'@tanstack/react-start-client': minor +'@tanstack/solid-start-client': minor +'@tanstack/start-client-core': minor +'@tanstack/start-plugin-core': minor +'@tanstack/start-server-core': minor +'@tanstack/router-core': patch +'@tanstack/router-plugin': patch +'@tanstack/router-utils': patch +--- + +Add deferred Hydrate boundary support for TanStack Start. + +Hydrate boundaries can now be code-split by the Start compiler, preload their generated client chunks, preserve server-rendered fallback HTML, and replay interaction-triggered events after hydration. The compiler integration now uses a Start-owned compiler plugin for Hydrate virtual modules across Vite and Rsbuild, with dev invalidation for generated virtual modules. + +Shared AST utilities used by the router code-splitter and Hydrate virtual modules were moved into `@tanstack/router-utils` so both pipelines can retain referenced top-level declarations, unwrap local exports, and let dead-code elimination remove unused route module code. diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md index ba3095f0ae9..f222b8088b5 100644 --- a/benchmarks/bundle-size/README.md +++ b/benchmarks/bundle-size/README.md @@ -13,6 +13,7 @@ Each package has `minimal` and `full` scenarios: - `minimal`: Small route app with `__root` + index route that renders `hello world` - `full`: Same route shape plus a broad root-level harness that imports/uses the full hooks/components surface - Start `full` scenarios also exercise `createServerFn`, `createMiddleware`, and `useServerFn` +- Start `deferred-hydration` scenarios match the minimal route shape and wrap the index route content in `Hydrate` ## Design Notes diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx new file mode 100644 index 00000000000..ff1da4c3046 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/about.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/about.tsx new file mode 100644 index 00000000000..06e0ec72230 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/about.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +import { idle, visible } from '@tanstack/react-start/hydration' + +export const Route = createFileRoute('/about')({ + component: AboutComponent, +}) + +function AboutComponent() { + return ( + +
hello about
+
+ ) +} diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx new file mode 100644 index 00000000000..0475e02c0dd --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +import { idle, visible } from '@tanstack/react-start/hydration' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return ( + +
hello world
+
+ ) +} diff --git a/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts new file mode 100644 index 00000000000..d4e4cd980d7 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import viteReact from '@vitejs/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), viteReact()], +}) diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx new file mode 100644 index 00000000000..aa7ead67524 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx new file mode 100644 index 00000000000..e59de722362 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,24 @@ +import { + HeadContent, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' + +export const Route = createRootRoute({ + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + ) +} diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/about.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/about.tsx new file mode 100644 index 00000000000..85c8ac4817e --- /dev/null +++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/about.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Hydrate } from '@tanstack/solid-start' +import { idle, visible } from '@tanstack/solid-start/hydration' + +export const Route = createFileRoute('/about')({ + component: AboutComponent, +}) + +function AboutComponent() { + return ( + +
hello about
+
+ ) +} diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx new file mode 100644 index 00000000000..a605375b7d6 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Hydrate } from '@tanstack/solid-start' +import { idle, visible } from '@tanstack/solid-start/hydration' + +export const Route = createFileRoute('/')({ + component: IndexComponent, +}) + +function IndexComponent() { + return ( + +
hello world
+
+ ) +} diff --git a/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts new file mode 100644 index 00000000000..0bd21e64f44 --- /dev/null +++ b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' + +export default defineConfig({ + plugins: [tanstackStart(), solid({ ssr: true })], +}) diff --git a/docs/start/config.json b/docs/start/config.json index 25d1a7b060d..95840588f3a 100644 --- a/docs/start/config.json +++ b/docs/start/config.json @@ -117,6 +117,10 @@ "label": "Hydration Errors", "to": "framework/react/guide/hydration-errors" }, + { + "label": "Deferred Hydration", + "to": "framework/react/guide/deferred-hydration" + }, { "label": "Selective SSR", "to": "framework/react/guide/selective-ssr" @@ -242,6 +246,10 @@ "label": "Hydration Errors", "to": "framework/solid/guide/hydration-errors" }, + { + "label": "Deferred Hydration", + "to": "framework/solid/guide/deferred-hydration" + }, { "label": "Selective SSR", "to": "framework/solid/guide/selective-ssr" diff --git a/docs/start/framework/react/guide/deferred-hydration.md b/docs/start/framework/react/guide/deferred-hydration.md new file mode 100644 index 00000000000..98792618059 --- /dev/null +++ b/docs/start/framework/react/guide/deferred-hydration.md @@ -0,0 +1,319 @@ +--- +id: deferred-hydration +title: Deferred Hydration +--- + +> Deferred hydration is experimental + +Server rendering gives users useful HTML quickly. Hydration is the client-side work that turns that HTML into an interactive app. That work includes loading the JavaScript for client components, executing that JavaScript, running the components, attaching event handlers, and reconnecting the rendered DOM to the app tree. + +On small pages this cost is usually fine. On large pages, especially on mobile devices, interactivity can be delayed by both parts of the cost: waiting for JavaScript to load and waiting for the browser to execute that JavaScript and hydrate the components. Hydrating the whole page immediately can also keep the main thread busy during the first interactions. Deferred hydration lets you keep the SSR HTML while delaying selected JavaScript loading and hydration work until that part of the page is likely to matter. + +This is useful when a page has content that should be visible and indexable immediately, but does not need to be interactive immediately. + +## A Motivating Example + +Imagine a product detail page: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { + idle, + interaction, + never, + visible, +} from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + <> + + + + + + + + } + > + + + + + + + + ) +} +``` + +In this page, the hero and buy box are critical. They should be interactive immediately, so they are not deferred. + +The reviews are useful SSR content, but they are below the initial viewport. `visible()` keeps their HTML in the document and hydrates them when the user scrolls near them. `prefetch={idle()}` lets TanStack Start start loading the generated child chunk during browser idle time so the reviews are more likely to be ready by the time they enter view. + +The recommendation carousel is expensive and only matters if the user interacts with it. `interaction()` delays hydration until there is user intent, while `prefetch={visible(...)}` can start downloading the chunk before the first interaction. + +The trust badges are static SSR HTML. `never()` keeps them non-interactive during initial hydration and avoids client work for that boundary. + +## Why This Requires Application Knowledge + +TanStack Start cannot know which parts of your app are safe to delay. The right boundary depends on your layout, your product priorities, and real user behavior. + +Good candidates are usually parts of the page that are visible in SSR HTML but not needed for immediate interaction: + +- Below-the-fold reviews, comments, related content, product details, or long marketing sections. +- Rich widgets such as maps, charts, carousels, video players, editors, or embeds. +- Panels that are visible later or activated by intent, such as filters, preview panes, or contextual tools. +- Responsive UI that only matters for a matching media query. +- Static server-rendered content that should never hydrate on the initial document. + +Poor candidates are parts of the page users expect to use immediately: + +- Primary navigation, route chrome, search boxes, and login/account controls. +- Above-the-fold forms, add-to-cart buttons, checkout actions, or consent controls. +- The interactive part of the LCP/hero area when users may click it immediately. +- Accessibility-critical controls that must be keyboard-ready as soon as the page appears. +- Components whose props or shared state are expected to update immediately after app startup. + +Use measurements to validate each boundary. Deferred hydration is a performance tool, not a blanket rule. A good boundary reduces startup JavaScript and main-thread work without making expected interactions feel late. + +## What Deferred Hydration Does + +Deferred hydration is different from `ssr: false` and `ssr: 'data-only'`. Those route options change whether a route renders HTML on the server. Deferred hydration still renders real SSR HTML, then delays selected client JavaScript work. + +TanStack Start keeps the normal app model: + +- One app root. +- Router context, loaders, links, head management, and SPA navigation. +- Server-rendered HTML for the deferred boundary. +- Client hydration when the chosen strategy resolves. + +By default, the compiler extracts each split `Hydrate` boundary's children into a separate client chunk. The server still renders the children normally, but the browser does not load and execute that child chunk until the boundary is ready or prefetched. + +## Basic Usage + +Use `Hydrate` with strategy factories from `@tanstack/react-start/hydration`: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + + + + ) +} +``` + +`Hydrate` only preserves server HTML for boundaries that are present in the initial server-rendered document. When a boundary first mounts after the app has already hydrated, such as after client-side navigation, TanStack Start renders it on the client because no server HTML exists to preserve. + +Use `fallback` for that no-SSR-DOM case only. It is shown if the boundary first mounts after the app is hydrated and the transformed child chunk, or another child `Suspense`, is still loading: + +```tsx +}> + + +``` + +`fallback` does not replace server-rendered HTML in the initial document. During initial hydration, TanStack Start preserves the existing server HTML until the boundary can hydrate. With `never()`, the initial server HTML remains static and `fallback` is not used. + +The compiler removes statically visible `fallback` props from the server bundle. Prefer passing `fallback` directly, in an inline object spread, or through a single-use `const` object spread so server builds can strip that UI completely. + +## Splitting And Prefetching + +By default, `Hydrate` splits the children into a generated child chunk. This delays both hydration work and child JavaScript loading. + +Set `split={false}` when you only want to delay hydration work without splitting the child code: + +```tsx +import { idle } from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + + + + ) +} +``` + +Because `prefetch` only loads the compiler-generated child chunk, it is only valid on split boundaries. TypeScript rejects `prefetch` when `split={false}`. + +Use `prefetch` when the child chunk should load before the boundary hydrates. `when` controls when the boundary becomes interactive. `prefetch` controls when TanStack Start calls the generated child chunk's lazy preload function: + +```tsx +import { idle, interaction, visible } from '@tanstack/react-start/hydration' + + + + + + + + +``` + +Common pairings: + +| Boundary goal | `when` | `prefetch` | +| ------------------------------------------ | -------------------- | ----------------------------------- | +| Hydrate below-the-fold content on scroll | `visible()` | `idle()` or none | +| Prepare content before it reaches viewport | `visible()` | `visible({ rootMargin: '1200px' })` | +| Keep a widget cold until user intent | `interaction()` | `visible(...)` or `idle()` | +| Hydrate non-critical work after startup | `idle()` | none | +| Hydrate only when app state says it is OK | `condition(isReady)` | `idle()`, `visible(...)`, or none | +| Keep initial SSR HTML static | `never()` | not supported | + +## Strategies + +`when` accepts a hydration strategy object: + +| Strategy | Behavior | +| --------------- | ------------------------------------------------------------------------------------------------------- | +| `load()` | Hydrates as soon as the app hydrates. | +| `idle()` | Hydrates in `requestIdleCallback`, or after `timeout` when idle callbacks are unavailable. | +| `visible()` | Hydrates when the boundary marker enters the viewport. | +| `media()` | Hydrates when the media query matches. | +| `interaction()` | Hydrates on the configured interaction intent events. Defaults to hover, focus, pointer down, or click. | +| `condition()` | Hydrates once the condition is truthy. | +| `never()` | Never hydrates the initial server-rendered boundary. | + +Use `never()` for intentionally static initial SSR HTML: + +```tsx +import { never } from '@tanstack/react-start/hydration' + +export function MarketingPage() { + return ( + + + + ) +} +``` + +`never()` keeps the existing server HTML static during initial hydration. If the same boundary mounts later during client-side navigation, it renders normally because no server HTML exists for TanStack Start to preserve. `never()` cannot be used as a `prefetch` strategy. + +Use `condition()` for app-specific one-time hydration conditions: + +```tsx +import { condition } from '@tanstack/react-start/hydration' + +export function CartPage() { + return ( + + + + ) +} +``` + +After a condition boundary hydrates, it stays hydrated even if `condition` later becomes false. + +For `interaction`, TanStack Start installs lightweight native intent listeners on the boundary marker, or on the nearest unresolved ancestor marker when a nested interaction boundary has not mounted yet. Those listeners open hydration gates and start deferred chunk loading. For bubbling intent events, TanStack Start queues a same-type event and redispatches it after the boundary hydrates so the first click-like interaction can reach React handlers. Native listener payload details such as pointer coordinates are not guaranteed to be preserved. + +The default interaction event list is `pointerenter`, `focusin`, `pointerdown`, and `click`. Use `events` when a boundary should listen to a different event or a smaller set: + +```tsx + + + + + + + +``` + +Nested boundaries use parent-first hydration. A child boundary can only hydrate after its ancestor boundaries have hydrated, so non-interaction child triggers such as `visible`, `media`, `idle`, or `condition` cannot fire while their parent boundary is still dehydrated. When a user shows interaction intent inside a nested unhydrated boundary, TanStack Start resolves the unresolved ancestor chain and marks the target boundary as intended. A `never()` ancestor still wins during initial hydration, so descendants under it remain non-interactive. + +## Settings + +`Hydrate` accepts these settings: + +| Option | Type | Notes | +| ------------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `when` | `HydrationStrategy` | Required. Use strategy factories from `@tanstack/react-start/hydration`, including `never()` for static initial SSR HTML. | +| `prefetch` | `HydrationPrefetchStrategy` | Optional split-boundary strategy for preloading the child chunk before hydration. Accepts `load`, `idle`, `visible`, `media`, and `interaction`. `never` and `condition` are not valid prefetch strategies. | +| `split` | `boolean` | Defaults to `true`. Set to literal `false` to disable compiler extraction. | +| `fallback` | `ReactNode` | Client-only loading UI used when the boundary mounts after the app has already hydrated and the child chunk or child `Suspense` is still loading. | +| `onHydrated` | `() => void` | Fires once after the boundary has actually hydrated on the client. | + +Strategy options: + +| Strategy | Options | +| ------------- | --------------------------------------------------------------------------------------- | +| `idle` | `{ timeout?: number }`, defaults to `2000`. | +| `visible` | `{ rootMargin?: string; threshold?: number \| Array }`, default margin `600px`. | +| `media` | Query string, for example `media('(min-width: 800px)')`. | +| `interaction` | `{ events?: supported event or readonly array of supported events }`. | +| `condition` | Boolean or boolean-returning function. | + +Supported interaction events are `auxclick`, `click`, `contextmenu`, `dblclick`, `focusin`, `keydown`, `keyup`, `mousedown`, `mouseenter`, `mouseover`, `mouseup`, `pointerdown`, `pointerenter`, `pointerover`, and `pointerup`. + +## Correctness And Updates + +Deferred hydration is a performance hint for React's initial hydration work. React may hydrate a deferred boundary earlier than its strategy would normally allow if state, props, context, or store updates outside the boundary require React to reconcile inside it before the gate opens. This preserves correctness and avoids showing stale server HTML after the surrounding app has changed. + +`never()` is the exception for initial document hydration. Treat it as intentionally static SSR HTML. Do not rely on parent updates to make a `never()` boundary interactive. If the same boundary mounts later during client-side navigation, it renders normally because no server HTML exists for TanStack Start to keep static. + +## Preloading And CSS + +TanStack Start does not modulepreload transformed `Hydrate` JavaScript chunks by default. Without `prefetch`, the child chunk loads when the split boundary is ready to render. If that import suspends during client-side navigation or another client-only mount, the boundary's `fallback` is shown. + +CSS from split, deferred, and `never()` boundaries remains attached to the route assets because the server-rendered HTML may need those styles before any JavaScript runs. CSS is separate from the JavaScript chunk loaded when a deferred split boundary renders. + +## Extraction Limits + +Compiler-backed `Hydrate` splitting works by moving the boundary's children into a generated virtual module and rendering them through a lazy component. That gives TanStack Start a separate child chunk to load later, but it also means the compiler must be able to move the JSX safely. + +The split boundary must use a statically imported `Hydrate` component from `@tanstack/react-start`. Renaming the import is supported, but dynamic component aliases are not analyzed. + +Use the literal prop `split={false}` to opt out of extraction. Dynamic values such as `split={shouldSplit}` cannot be used to opt out at compile time. + +These patterns cannot be split: + +| Pattern | Why it is rejected | What to do instead | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| Function-as-children | The compiler cannot move a render function and preserve the expected call pattern. | Use `split={false}` or move the rendered UI into a child component. | +| Hook calls directly inside extracted JSX | Moving that JSX would move where the hook executes. | Move the hook call into a component inside the boundary, then render that component. | +| `this` captures | Extracted function components cannot safely preserve class instance context. | Wrap the UI in a function component or use `split={false}`. | +| `super` captures | Extracted function components cannot preserve superclass access. | Wrap the UI in a function component or use `split={false}`. | + +This fails because `useThing()` would be moved into the generated component: + +```tsx + +

{useThing()}

+
+``` + +Move the hook into a component instead: + +```tsx +function ThingText() { + const thing = useThing() + return

{thing}

+} + +export function ProductPage() { + return ( + + + + ) +} +``` + +Values captured from the surrounding component can be passed into the generated child component, but keep the boundary simple. If extraction starts forcing complicated data flow, prefer a named child component and put the logic there. + +`fallback` stripping is also intentionally conservative. The server build can strip directly passed fallback UI, inline object-spread fallback UI, and single-use `const` object-spread fallback UI. If fallback props are hidden behind dynamic spreads or shared objects, the compiler may keep them. + +For browser-only rendering with no SSR HTML, use `ClientOnly` instead. For route-level SSR control, use [Selective SSR](./selective-ssr.md). diff --git a/docs/start/framework/solid/guide/deferred-hydration.md b/docs/start/framework/solid/guide/deferred-hydration.md new file mode 100644 index 00000000000..99ffc57a86a --- /dev/null +++ b/docs/start/framework/solid/guide/deferred-hydration.md @@ -0,0 +1,12 @@ +--- +ref: docs/start/framework/react/guide/deferred-hydration.md +replace: + '@tanstack/react-start': '@tanstack/solid-start' + 'React handlers': 'Solid handlers' + 'ReactNode': 'JSX.Element' + "Deferred hydration is a performance hint for React's initial hydration work. React may hydrate a deferred boundary earlier than its strategy would normally allow if state, props, context, or store updates outside the boundary require React to reconcile inside it before the gate opens. This preserves correctness and avoids showing stale server HTML after the surrounding app has changed.": "Deferred hydration is a performance hint for Solid's initial hydration work. Once a boundary gate opens, TanStack Start clears the preserved server DOM inside the marker and mounts the live Solid subtree in its place." + 'Hook calls directly inside extracted JSX': 'Render-time `use*` calls directly inside extracted JSX' + 'Moving that JSX would move where the hook executes.': 'Moving that JSX would move where the call executes.' + 'Move the hook call into a component inside the boundary, then render that component.': 'Move the call into a component inside the boundary, then render that component.' + 'Move the hook into a component instead:': 'Move the call into a component instead:' +--- diff --git a/e2e/react-start/deferred-hydration/.gitignore b/e2e/react-start/deferred-hydration/.gitignore new file mode 100644 index 00000000000..1b3a07ede12 --- /dev/null +++ b/e2e/react-start/deferred-hydration/.gitignore @@ -0,0 +1,15 @@ +node_modules +package-lock.json +yarn.lock +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-start/deferred-hydration/package.json b/e2e/react-start/deferred-hydration/package.json new file mode 100644 index 00000000000..220b5a2f83f --- /dev/null +++ b/e2e/react-start/deferred-hydration/package.json @@ -0,0 +1,56 @@ +{ + "name": "tanstack-react-start-e2e-deferred-hydration", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm dev:vite --port 3000", + "dev:e2e": "pnpm dev:vite", + "dev:vite": "vite dev", + "dev:rsbuild": "rsbuild dev", + "build": "pnpm build:vite", + "build:vite": "vite build && tsc --noEmit", + "build:rsbuild": "rsbuild build && tsc --noEmit", + "preview": "vite preview", + "start": "node server.js", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^8.0.0" + }, + "devDependencies": { + "@rsbuild/core": "^2.0.1", + "@rsbuild/plugin-react": "^2.0.0", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "rolldown": "1.0.0-rc.18", + "srvx": "^0.11.9", + "typescript": "^6.0.2" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + } + }, + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "ssr" + } + ] + } + } +} diff --git a/e2e/react-start/deferred-hydration/playwright.config.ts b/e2e/react-start/deferred-hydration/playwright.config.ts new file mode 100644 index 00000000000..c545e5c64fa --- /dev/null +++ b/e2e/react-start/deferred-hydration/playwright.config.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs' +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' +const distDir = process.env.E2E_DIST_DIR ?? `dist-${toolchain}-ssr` +const e2ePortKey = + process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}` + +if (process.env.TEST_WORKER_INDEX === undefined) { + fs.rmSync(`port-${e2ePortKey}.txt`, { force: true }) +} + +const PORT = await getTestServerPort(e2ePortKey) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: `pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + E2E_DIST_DIR: distDir, + NODE_ENV: 'production', + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/deferred-hydration/rsbuild.config.ts b/e2e/react-start/deferred-hydration/rsbuild.config.ts new file mode 100644 index 00000000000..6279705adb2 --- /dev/null +++ b/e2e/react-start/deferred-hydration/rsbuild.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginReact } from '@rsbuild/plugin-react' +import { tanstackStart } from '@tanstack/react-start/plugin/rsbuild' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist-rsbuild-ssr' + +export default defineConfig({ + plugins: [pluginReact({ splitChunks: false }), tanstackStart()], + output: { + distPath: { + root: outDir, + }, + }, +}) diff --git a/e2e/react-start/deferred-hydration/server.js b/e2e/react-start/deferred-hydration/server.js new file mode 100644 index 00000000000..1c155e3f23b --- /dev/null +++ b/e2e/react-start/deferred-hydration/server.js @@ -0,0 +1,99 @@ +import { createReadStream, existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import path from 'node:path' +import { toNodeHandler } from 'srvx/node' + +const port = process.env.PORT || 3000 +const distDir = process.env.E2E_DIST_DIR || 'dist-vite-ssr' +const distClientDir = path.resolve(distDir, 'client') +const staticContentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', +} + +function resolveDistServerEntryPath() { + const candidates = [ + path.resolve(distDir, 'server', 'server.js'), + path.resolve(distDir, 'server', 'index.js'), + path.resolve(distDir, 'server', 'index.mjs'), + ] + + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] +} + +function getStaticFilePath(requestUrl) { + const url = new URL(requestUrl, `http://localhost:${port}`) + let decodedPath + try { + decodedPath = decodeURIComponent(url.pathname) + } catch { + return null + } + + const filePath = path.resolve(distClientDir, `.${decodedPath}`) + + if (!filePath.startsWith(`${distClientDir}${path.sep}`)) { + return null + } + + return filePath +} + +async function tryServeStatic(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + return false + } + + const filePath = getStaticFilePath(req.url ?? '/') + if (!filePath) { + return false + } + + const fileStat = await stat(filePath).catch(() => null) + if (!fileStat?.isFile()) { + return false + } + + res.statusCode = 200 + res.setHeader( + 'content-type', + staticContentTypes[path.extname(filePath)] ?? 'application/octet-stream', + ) + res.setHeader('content-length', String(fileStat.size)) + + if (req.method === 'HEAD') { + res.end() + return true + } + + createReadStream(filePath).pipe(res) + return true +} + +async function createStartServer() { + const server = (await import(resolveDistServerEntryPath())).default + const nodeHandler = toNodeHandler(server.fetch) + + return createServer(async (req, res) => { + if (await tryServeStatic(req, res)) { + return + } + + await nodeHandler(req, res) + }) +} + +createStartServer().then((server) => { + server.listen(port, () => { + console.info(`Start Server: http://localhost:${port}`) + }) +}) diff --git a/e2e/react-start/deferred-hydration/src/routeTree.gen.ts b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts new file mode 100644 index 00000000000..700c7c65909 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ImportedRouteImport } from './routes/imported' +import { Route as CssRouteImport } from './routes/css' +import { Route as ComponentsRouteImport } from './routes/components' +import { Route as IndexRouteImport } from './routes/index' + +const ImportedRoute = ImportedRouteImport.update({ + id: '/imported', + path: '/imported', + getParentRoute: () => rootRouteImport, +} as any) +const CssRoute = CssRouteImport.update({ + id: '/css', + path: '/css', + getParentRoute: () => rootRouteImport, +} as any) +const ComponentsRoute = ComponentsRouteImport.update({ + id: '/components', + path: '/components', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/components' | '/css' | '/imported' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/components' | '/css' | '/imported' + id: '__root__' | '/' | '/components' | '/css' | '/imported' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ComponentsRoute: typeof ComponentsRoute + CssRoute: typeof CssRoute + ImportedRoute: typeof ImportedRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/imported': { + id: '/imported' + path: '/imported' + fullPath: '/imported' + preLoaderRoute: typeof ImportedRouteImport + parentRoute: typeof rootRouteImport + } + '/css': { + id: '/css' + path: '/css' + fullPath: '/css' + preLoaderRoute: typeof CssRouteImport + parentRoute: typeof rootRouteImport + } + '/components': { + id: '/components' + path: '/components' + fullPath: '/components' + preLoaderRoute: typeof ComponentsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ComponentsRoute: ComponentsRoute, + CssRoute: CssRoute, + ImportedRoute: ImportedRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/deferred-hydration/src/router.tsx b/e2e/react-start/deferred-hydration/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/__root.tsx b/e2e/react-start/deferred-hydration/src/routes/__root.tsx new file mode 100644 index 00000000000..719223992e3 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,163 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Deferred Hydration E2E' }, + ], + }), + shellComponent: RootDocument, + component: () => ( +
+ +
+ ), +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/components.tsx b/e2e/react-start/deferred-hydration/src/routes/components.tsx new file mode 100644 index 00000000000..95fa7531a47 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/components.tsx @@ -0,0 +1,179 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start' +import { + condition, + idle, + interaction, + load, + media, + never, + visible, +} from '@tanstack/react-start/hydration' + +export const Route = createFileRoute('/components')({ + component: ComponentHydrationPage, +}) + +function InteractiveBox(props: { id: string; label: string }) { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +type HydrationFallbackWindow = Window & { + __componentFallbackReady?: boolean + __componentFallbackPromise?: Promise +} + +function DelayedFallbackBox() { + if (typeof window !== 'undefined') { + const win = window as HydrationFallbackWindow + + if (!win.__componentFallbackReady) { + win.__componentFallbackPromise ??= new Promise((resolve) => { + win.setTimeout(() => { + win.__componentFallbackReady = true + resolve() + }, 1000) + }) + + throw win.__componentFallbackPromise + } + } + + return
fallback child
+} + +function ComponentHydrationPage() { + const [hydratedCallbacks, setHydratedCallbacks] = React.useState(0) + const [conditionReady, setConditionReady] = React.useState(false) + const [showClientFallbackBoundary, setShowClientFallbackBoundary] = + React.useState(false) + + return ( +
+

Component Deferred Hydration

+
+ Manual test guide + + Pink buttons are server HTML that has not hydrated yet. Green buttons + have hydrated and should increment when clicked. Follow the notes + below to trigger each strategy intentionally. + +
+

{hydratedCallbacks}

+

+ load and idle should become green + without interaction shortly after the page loads. +

+ + + + + + +
+ Scroll down to reveal the visible boundary +
+

+ visible hydrates only after this button enters the + viewport. +

+ + + +

+ media hydrates when (min-width: 1px) + matches. interaction hydrates on hover, focus, pointer + down, or click intent. +

+ + + + + + +

+ Custom interaction boundaries below hydrate only for their configured + events: double-click for the single-event example, and right-click or + double-click for the multi-event example. The prefetch example should + download code on hover but hydrate on click. +

+ setHydratedCallbacks((count) => count + 1)} + > + + + + + + + + + + + + + + + + + + + + + + + +

+ never stays as server HTML forever on the initial page, + so clicking should not increment it. +

+ + {showClientFallbackBoundary ? ( + client fallback + } + > + + + ) : null} +
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/css.tsx b/e2e/react-start/deferred-hydration/src/routes/css.tsx new file mode 100644 index 00000000000..2c01aa9e578 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/css.tsx @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +import { media, never, visible } from '@tanstack/react-start/hydration' +import outerStyles from './css/outer.module.css' +import deferredStyles from './css/deferred-only.module.css' +import sharedStyles from './css/shared.module.css' + +export const Route = createFileRoute('/css')({ + component: CssHydrationPage, +}) + +function CssHydrationPage() { + return ( +
+
+

+ CSS Deferred Hydration +

+

+ CSS from deferred, never, shared, and nested Hydrate boundaries should + be available even before the client JavaScript hydrates those islands. +

+
+
+
+ Outer CSS +
+
+ Shared outer CSS +
+
+ +
+ Deferred CSS +
+
+ +
+ Never CSS +
+
+ + +
+ Nested CSS +
+
+
+
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css b/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css new file mode 100644 index 00000000000..05eafda03cf --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/css/deferred-only.module.css @@ -0,0 +1,15 @@ +.deferredBox { + background-color: rgb(23, 45, 67); + color: rgb(255, 255, 255); + padding: 12px; +} + +.neverBox { + color: rgb(45, 67, 89); + padding: 12px; +} + +.nestedBox { + border-left: 5px solid rgb(67, 89, 123); + padding-left: 12px; +} diff --git a/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css b/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css new file mode 100644 index 00000000000..98ca5e0934e --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/css/outer.module.css @@ -0,0 +1,9 @@ +.heading { + color: rgb(11, 31, 53); +} + +.outerBox { + background-color: rgb(242, 250, 255); + color: rgb(12, 34, 56); + padding: 12px; +} diff --git a/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css b/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css new file mode 100644 index 00000000000..020da5d7adc --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/css/shared.module.css @@ -0,0 +1,4 @@ +.sharedBox { + border-top: 4px solid rgb(98, 76, 54); + margin-top: 8px; +} diff --git a/e2e/react-start/deferred-hydration/src/routes/imported.tsx b/e2e/react-start/deferred-hydration/src/routes/imported.tsx new file mode 100644 index 00000000000..bf0f8b74bdf --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/imported.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router' +import { ImportedHydrateWidget } from '../shared/ImportedHydrateWidget' + +export const Route = createFileRoute('/imported')({ + component: ImportedHydrationPage, +}) + +function ImportedHydrationPage() { + return ( +
+

Imported Hydrate

+ +
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/index.tsx b/e2e/react-start/deferred-hydration/src/routes/index.tsx new file mode 100644 index 00000000000..492148887dc --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { Link, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Deferred Hydration

+

Component strategies

+ component strategies +

CSS

+ CSS deferred hydration +

Imported component

+ + imported Hydrate + +
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx b/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx new file mode 100644 index 00000000000..4ee8c5e2f44 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start' +import { media } from '@tanstack/react-start/hydration' + +function ImportedHydrateChild() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} + +export function ImportedHydrateWidget() { + return ( + + imported hydrate fallback + + } + > + + + ) +} diff --git a/e2e/react-start/deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts new file mode 100644 index 00000000000..18af0d20449 --- /dev/null +++ b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts @@ -0,0 +1,519 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { APIRequestContext, Page } from '@playwright/test' + +async function clickAndExpectCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(countTestId)).toHaveText(count) +} + +async function hoverIntentAndExpectCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.mouse.move(0, 0) + await page.getByTestId(buttonTestId).hover() + await clickAndExpectCount(page, buttonTestId, countTestId, count) +} + +async function dispatchHydrationIntent( + page: Page, + buttonTestId: string, + eventName: string, +) { + await page.getByTestId(buttonTestId).evaluate((element, eventName) => { + const marker = element.closest('[data-ts-hydrate-id]') + + if (!marker) { + throw new Error('Expected Hydrate marker to exist') + } + + marker.dispatchEvent( + new Event(eventName, { bubbles: true, cancelable: true }), + ) + }, eventName) +} + +async function expectRouteToStayUnhydrated( + page: Page, + buttonTestId: string, + duration = 250, +) { + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await page.waitForTimeout(duration) + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'false', + ) +} + +async function scrollToBoundary(page: Page, buttonTestId: string) { + const button = page.getByTestId(buttonTestId) + for (let attempt = 0; attempt < 3; attempt++) { + await button.evaluate((element) => { + element.scrollIntoView({ block: 'center', inline: 'nearest' }) + }) + + await page.waitForTimeout(100) + const isVisible = await button.evaluate((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > 0 && rect.top < window.innerHeight + }) + + if (isVisible) return + } + + await expect(button).toBeInViewport() +} + +async function expectCssProperty( + page: Page, + testId: string, + property: string, + value: string, +) { + await expect + .poll(() => + page.getByTestId(testId).evaluate((element, propertyName) => { + return getComputedStyle(element).getPropertyValue(propertyName) + }, property), + ) + .toBe(value) +} + +function htmlContainsText(html: string, text: string) { + const pattern = text.split(' ').join('(?:\\s|)+') + expect(html).toMatch(new RegExp(pattern)) +} + +function getModulePreloadHrefs(html: string) { + return Array.from(html.matchAll(/]*>/g), (match) => match[0]) + .filter((tag) => /\brel="modulepreload"/.test(tag)) + .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1]) + .filter((href): href is string => !!href) +} + +async function modulePreloadContentsContain( + request: APIRequestContext, + hrefs: Array, + marker: string, +) { + for (const href of hrefs) { + const response = await request.get(href) + if (!response.ok()) continue + + const text = await response.text() + if (text.includes(marker)) return true + } + + return false +} + +async function resourceContentsContain( + page: Page, + request: APIRequestContext, + marker: string, + filter: (url: string) => boolean, +) { + const resourceUrls = await page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => entry.name), + ) + + return modulePreloadContentsContain( + request, + resourceUrls.filter(filter), + marker, + ) +} + +async function documentModulePreloadHrefs(page: Page) { + return page.evaluate(() => + Array.from( + document.querySelectorAll('link[rel~="modulepreload"]'), + (link) => link.href, + ), + ) +} + +function isHydrateBoundaryResource(url: string) { + return ( + url.includes('/assets/components-') || url.includes('/static/js/async/') + ) +} + +function isClientJavaScriptResource(url: string) { + return ( + url.includes('/assets/') || + url.includes('/static/js/') || + url.includes('/static/js/async/') + ) +} + +async function expectClientRouterReady(page: Page) { + await expect + .poll(() => + page.evaluate(() => + Boolean( + ( + globalThis as typeof globalThis & { + __TSR_ROUTER__?: unknown + } + ).__TSR_ROUTER__, + ), + ), + ) + .toBe(true) +} + +test.describe('component-level Hydrate runtime strategies', () => { + test('renders SSR HTML and hydrates each runtime when appropriately', async ({ + page, + request, + }) => { + await page.goto('/components') + + await expect(page.getByTestId('component-heading')).toHaveText( + 'Component Deferred Hydration', + ) + + await clickAndExpectCount( + page, + 'component-load-button', + 'component-load-count', + '1', + ) + await clickAndExpectCount( + page, + 'component-idle-button', + 'component-idle-count', + '1', + ) + await expect( + resourceContentsContain(page, request, 'component-visible', (url) => + isHydrateBoundaryResource(url), + ), + ).resolves.toBe(false) + await expectRouteToStayUnhydrated(page, 'component-visible-button') + await scrollToBoundary(page, 'component-visible-button') + await clickAndExpectCount( + page, + 'component-visible-button', + 'component-visible-count', + '1', + ) + await expect + .poll(() => + resourceContentsContain(page, request, 'component-visible', (url) => + isHydrateBoundaryResource(url), + ), + ) + .toBe(true) + await clickAndExpectCount( + page, + 'component-media-button', + 'component-media-count', + '1', + ) + await hoverIntentAndExpectCount( + page, + 'component-interaction-button', + 'component-interaction-count', + '1', + ) + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '0', + ) + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await page.getByTestId('component-custom-single-button').hover() + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await page.getByTestId('component-custom-single-button').click() + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await dispatchHydrationIntent( + page, + 'component-custom-single-button', + 'dblclick', + ) + await expect( + page.getByTestId('component-custom-single-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '1', + ) + await clickAndExpectCount( + page, + 'component-custom-single-button', + 'component-custom-single-count', + '1', + ) + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-custom-multi-button') + await dispatchHydrationIntent( + page, + 'component-custom-multi-button', + 'contextmenu', + ) + await clickAndExpectCount( + page, + 'component-custom-multi-button', + 'component-custom-multi-count', + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-condition-button') + await page.getByTestId('component-enable-condition').click() + await clickAndExpectCount( + page, + 'component-condition-button', + 'component-condition-count', + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-click-replay-button') + await page.getByTestId('component-click-replay-button').click() + await expect( + page.getByTestId('component-click-replay-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-click-replay-count')).toHaveText( + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-prefetch-button') + await expect( + resourceContentsContain(page, request, 'component-prefetch', (url) => + isHydrateBoundaryResource(url), + ), + ).resolves.toBe(false) + await page.mouse.move(0, 0) + await page.getByTestId('component-prefetch-button').hover() + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await expect + .poll(() => + resourceContentsContain(page, request, 'component-prefetch', (url) => + isHydrateBoundaryResource(url), + ), + ) + .toBe(true) + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await page.getByTestId('component-prefetch-button').click() + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId('component-prefetch-count')).toHaveText('1') + await hoverIntentAndExpectCount( + page, + 'component-nested-child-button', + 'component-nested-child-count', + '1', + ) + + await page.getByTestId('component-never-button').click() + await expect(page.getByTestId('component-never-count')).toHaveText('0') + }) + + test('replays click after another interaction boundary hydrates first', async ({ + page, + }) => { + await page.goto('/components') + await expectClientRouterReady(page) + + await scrollToBoundary(page, 'component-custom-multi-button') + await expectRouteToStayUnhydrated(page, 'component-custom-multi-button') + await page.getByTestId('component-custom-multi-button').click({ + button: 'right', + }) + await expect( + page.getByTestId('component-custom-multi-button'), + ).toHaveAttribute('data-hydrated', 'true') + + await scrollToBoundary(page, 'component-click-replay-button') + await expectRouteToStayUnhydrated(page, 'component-click-replay-button') + await page.getByTestId('component-click-replay-button').click() + await expect( + page.getByTestId('component-click-replay-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-click-replay-count')).toHaveText( + '1', + ) + }) + + test('shows fallback during a client-only mount while the child suspends', async ({ + page, + }) => { + await page.goto('/components') + await expect(page.getByTestId('component-load-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.getByTestId('component-show-client-fallback').click() + + await expect(page.getByTestId('component-client-fallback')).toHaveText( + 'client fallback', + ) + await expect(page.getByTestId('component-fallback-child')).toHaveText( + 'fallback child', + ) + await expect(page.getByTestId('component-client-fallback')).toHaveCount(0) + }) +}) + +test.describe('Hydrate CSS delivery', () => { + test('ships CSS for deferred, never, shared, and nested boundaries without JavaScript', async ({ + browser, + request, + }) => { + const response = await request.get('/css') + const html = await response.text() + + htmlContainsText(html, 'CSS Deferred Hydration') + htmlContainsText(html, 'Outer CSS') + htmlContainsText(html, 'Deferred CSS') + htmlContainsText(html, 'Never CSS') + htmlContainsText(html, 'Nested CSS') + + const context = await browser.newContext({ javaScriptEnabled: false }) + const page = await context.newPage() + + try { + await page.goto('/css') + + await expect(page.getByTestId('css-heading')).toHaveText( + 'CSS Deferred Hydration', + ) + await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS') + await expect(page.getByTestId('css-never')).toHaveText('Never CSS') + await expect(page.getByTestId('css-nested')).toHaveText('Nested CSS') + + await expectCssProperty(page, 'css-outer', 'color', 'rgb(12, 34, 56)') + await expectCssProperty( + page, + 'css-deferred', + 'background-color', + 'rgb(23, 45, 67)', + ) + await expectCssProperty(page, 'css-never', 'color', 'rgb(45, 67, 89)') + await expectCssProperty( + page, + 'css-shared-outer', + 'border-top-color', + 'rgb(98, 76, 54)', + ) + await expectCssProperty( + page, + 'css-deferred', + 'border-top-color', + 'rgb(98, 76, 54)', + ) + await expectCssProperty( + page, + 'css-nested', + 'border-left-color', + 'rgb(67, 89, 123)', + ) + await expectCssProperty(page, 'css-nested', 'border-left-width', '5px') + } finally { + await context.close() + } + }) + + test('renders deferred content and omits never content after client-side navigation', async ({ + page, + }) => { + await page.goto('/') + await expectClientRouterReady(page) + await page.getByRole('link', { name: 'CSS', exact: true }).click() + await expect(page).toHaveURL(/\/css$/) + + await expect(page.getByTestId('css-heading')).toHaveText( + 'CSS Deferred Hydration', + ) + await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS') + await expect(page.getByTestId('css-never')).toHaveCount(0) + await expect(page.getByTestId('css-nested')).toHaveCount(0) + + await expectCssProperty( + page, + 'css-deferred', + 'background-color', + 'rgb(23, 45, 67)', + ) + }) +}) + +test.describe('imported Hydrate boundaries', () => { + test('does not emit filtered shared Hydrate child JS on the initial document', async ({ + request, + }) => { + const response = await request.get('/imported') + const html = await response.text() + + htmlContainsText(html, 'Imported Hydrate') + htmlContainsText(html, 'Imported Hydrate Child') + + await expect( + modulePreloadContentsContain( + request, + getModulePreloadHrefs(html), + 'imported-hydrate-child', + ), + ).resolves.toBe(false) + }) + + test('does not preload Hydrate child chunks before client navigation', async ({ + page, + request, + }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toHaveText( + 'Deferred Hydration', + ) + await expectClientRouterReady(page) + + const link = page.getByRole('link', { name: 'imported Hydrate' }) + await page.mouse.move(0, 0) + await link.hover() + await link.focus() + + await expect( + modulePreloadContentsContain( + request, + await documentModulePreloadHrefs(page), + 'imported-hydrate-child', + ), + ).resolves.toBe(false) + await expect( + resourceContentsContain(page, request, 'imported-hydrate-child', (url) => + isClientJavaScriptResource(url), + ), + ).resolves.toBe(false) + + await page.getByRole('link', { name: 'imported Hydrate' }).click() + await expect(page).toHaveURL(/\/imported$/) + await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0) + await expect(page.getByTestId('imported-hydrate-child')).toContainText( + 'Imported Hydrate Child', + ) + await page.getByTestId('imported-hydrate-child').click() + await expect(page.getByTestId('imported-hydrate-count')).toHaveText('1') + }) +}) diff --git a/e2e/react-start/deferred-hydration/tsconfig.json b/e2e/react-start/deferred-hydration/tsconfig.json new file mode 100644 index 00000000000..cef9369516a --- /dev/null +++ b/e2e/react-start/deferred-hydration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/deferred-hydration/vite.config.ts b/e2e/react-start/deferred-hydration/vite.config.ts new file mode 100644 index 00000000000..1289bc0be78 --- /dev/null +++ b/e2e/react-start/deferred-hydration/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist-vite-ssr' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + build: { + outDir, + }, + server: { port: 3000 }, + plugins: [tanstackStart(), viteReact()], +}) diff --git a/e2e/react-start/rsc-deferred-hydration/.gitignore b/e2e/react-start/rsc-deferred-hydration/.gitignore new file mode 100644 index 00000000000..cc99170fbbf --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +port*.txt +test-results +playwright-report diff --git a/e2e/react-start/rsc-deferred-hydration/package.json b/e2e/react-start/rsc-deferred-hydration/package.json new file mode 100644 index 00000000000..7944b815527 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/package.json @@ -0,0 +1,45 @@ +{ + "name": "tanstack-react-start-e2e-rsc-deferred-hydration", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "vite dev --port 3000", + "dev:e2e": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "node server.js", + "test:e2e": "pnpm test:e2e:dev && pnpm test:e2e:prod", + "test:e2e:dev": "MODE=dev playwright test --project=chromium", + "test:e2e:prod": "MODE=prod playwright test --project=chromium" + }, + "nx": { + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr", + "shards": 1 + } + ] + } + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-start": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^8.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-rsc": "^0.5.20", + "srvx": "^0.11.9", + "typescript": "^6.0.2" + } +} diff --git a/e2e/react-start/rsc-deferred-hydration/playwright.config.ts b/e2e/react-start/rsc-deferred-hydration/playwright.config.ts new file mode 100644 index 00000000000..10f41cd911b --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +const mode = process.env.MODE ?? 'prod' +const isDev = mode === 'dev' + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + use: { baseURL }, + webServer: { + command: isDev ? 'pnpm dev:e2e' : 'pnpm build && pnpm start', + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + VITE_NODE_ENV: 'test', + NODE_ENV: isDev ? 'development' : 'production', + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-start/rsc-deferred-hydration/server.js b/e2e/react-start/rsc-deferred-hydration/server.js new file mode 100644 index 00000000000..6365c568d44 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/server.js @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { pathToFileURL } from 'node:url' + +const distDir = process.env.E2E_DIST_DIR || 'dist' + +function resolveDistClientDir() { + return path.resolve(distDir, 'client') +} + +function resolveDistServerEntryPath() { + const serverJsPath = path.resolve(distDir, 'server', 'server.js') + if (fs.existsSync(serverJsPath)) return serverJsPath + + const indexJsPath = path.resolve(distDir, 'server', 'index.js') + if (fs.existsSync(indexJsPath)) return indexJsPath + + return serverJsPath +} + +export function start() { + const child = spawn( + 'srvx', + ['--prod', '-s', resolveDistClientDir(), resolveDistServerEntryPath()], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ) + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(code ?? 0) + }) +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + start() +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css new file mode 100644 index 00000000000..d80e17aebe9 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.module.css @@ -0,0 +1,42 @@ +.cssIsland { + border-color: rgba(16, 185, 129, 0.38); + background: + linear-gradient( + 135deg, + rgba(236, 253, 245, 0.96), + rgba(255, 255, 255, 0.9) + ), + repeating-linear-gradient( + 45deg, + rgba(16, 185, 129, 0.12) 0 8px, + transparent 8px 16px + ); + color: rgb(6, 78, 59); +} + +.cssIsland h2 { + color: rgb(6, 95, 70); +} + +.cssMarker { + width: fit-content; + padding: 0.45rem 0.7rem; + border-radius: 999px; + font-weight: 900; + transition: + background 180ms ease, + color 180ms ease, + box-shadow 180ms ease; +} + +.cssMarkerPending { + background: rgb(252, 231, 243); + color: rgb(157, 23, 77); + box-shadow: 0 8px 26px rgba(219, 39, 119, 0.18); +} + +.cssMarkerHydrated { + background: rgb(209, 250, 229); + color: rgb(6, 95, 70); + box-shadow: 0 8px 26px rgba(5, 150, 105, 0.18); +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx new file mode 100644 index 00000000000..cf49269f12f --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/components/CssHydrateIsland.tsx @@ -0,0 +1,45 @@ +'use client' + +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start/client' +import { media } from '@tanstack/react-start/hydration' +import { DeferredHydrateIsland } from './DeferredHydrateIsland' +import styles from './CssHydrateIsland.module.css' + +function CssHydratePanel() { + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( +
+ CSS module Hydrate island +

CSS modules survive the RSC to client boundary

+

+ {hydrated + ? 'Hydrated module-styled client content' + : 'Pending module-styled client content'} +

+ +
+ ) +} + +export function CssHydrateIsland() { + return ( +
+ + + +
+ ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx b/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx new file mode 100644 index 00000000000..5444fef057d --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/components/DeferredHydrateIsland.tsx @@ -0,0 +1,67 @@ +'use client' + +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start/client' +import { interaction, media, visible } from '@tanstack/react-start/hydration' + +type Strategy = 'interaction' | 'visible' | 'media' + +const strategyCopy: Record = { + interaction: 'Hydrates after pointer or focus intent reaches this island.', + visible: 'Hydrates only after the island scrolls into the viewport.', + media: 'Hydrates immediately when the matching media query is true.', +} + +function getStrategy(strategy: Strategy) { + if (strategy === 'interaction') return interaction() + if (strategy === 'visible') return visible({ rootMargin: '0px' }) + return media('(min-width: 1px)') +} + +function CounterButton(props: { id: string; label: string }) { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +export function DeferredHydrateIsland(props: { + id: string + title: string + strategy: Strategy + className?: string +}) { + return ( +
+ Client Hydrate island +

{props.title}

+

{strategyCopy[props.strategy]}

+ + + +
+ ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts b/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts new file mode 100644 index 00000000000..246737b5153 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ServerClientRouteImport } from './routes/server-client' +import { Route as CssRouteImport } from './routes/css' +import { Route as CompositeRouteImport } from './routes/composite' +import { Route as IndexRouteImport } from './routes/index' + +const ServerClientRoute = ServerClientRouteImport.update({ + id: '/server-client', + path: '/server-client', + getParentRoute: () => rootRouteImport, +} as any) +const CssRoute = CssRouteImport.update({ + id: '/css', + path: '/css', + getParentRoute: () => rootRouteImport, +} as any) +const CompositeRoute = CompositeRouteImport.update({ + id: '/composite', + path: '/composite', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/composite': typeof CompositeRoute + '/css': typeof CssRoute + '/server-client': typeof ServerClientRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/composite': typeof CompositeRoute + '/css': typeof CssRoute + '/server-client': typeof ServerClientRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/composite': typeof CompositeRoute + '/css': typeof CssRoute + '/server-client': typeof ServerClientRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/composite' | '/css' | '/server-client' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/composite' | '/css' | '/server-client' + id: '__root__' | '/' | '/composite' | '/css' | '/server-client' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + CompositeRoute: typeof CompositeRoute + CssRoute: typeof CssRoute + ServerClientRoute: typeof ServerClientRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/server-client': { + id: '/server-client' + path: '/server-client' + fullPath: '/server-client' + preLoaderRoute: typeof ServerClientRouteImport + parentRoute: typeof rootRouteImport + } + '/css': { + id: '/css' + path: '/css' + fullPath: '/css' + preLoaderRoute: typeof CssRouteImport + parentRoute: typeof rootRouteImport + } + '/composite': { + id: '/composite' + path: '/composite' + fullPath: '/composite' + preLoaderRoute: typeof CompositeRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + CompositeRoute: CompositeRoute, + CssRoute: CssRoute, + ServerClientRoute: ServerClientRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/router.tsx b/e2e/react-start/rsc-deferred-hydration/src/router.tsx new file mode 100644 index 00000000000..9d87d8748b5 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx new file mode 100644 index 00000000000..7ce2e7bdb13 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,122 @@ +/// +import * as React from 'react' +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'RSC Deferred Hydration E2E' }, + ], + }), + shellComponent: RootDocument, + component: () => ( +
+ +
+ ), +}) + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + {children} + + + + ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx new file mode 100644 index 00000000000..3241ee487d6 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routes/composite.tsx @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import { CompositeComponent } from '@tanstack/react-start/rsc' +import { getCompositeHydrate } from '~/server/serverHydrateComponents' +import { DeferredHydrateIsland } from '~/components/DeferredHydrateIsland' + +export const Route = createFileRoute('/composite')({ + loader: async () => ({ + Composite: await getCompositeHydrate(), + }), + component: CompositeRoute, +}) + +function CompositeRoute() { + const { Composite } = Route.useLoaderData() + return ( + + + + ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx new file mode 100644 index 00000000000..ddb22f318a3 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routes/css.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getCssModuleHydrate } from '~/server/serverHydrateComponents' + +export const Route = createFileRoute('/css')({ + loader: async () => ({ + Server: await getCssModuleHydrate(), + }), + component: CssRoute, +}) + +function CssRoute() { + const { Server } = Route.useLoaderData() + return Server +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx new file mode 100644 index 00000000000..ebe57e70281 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routes/index.tsx @@ -0,0 +1,35 @@ +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

RSC meets Deferred Hydration

+

+ These routes render React Server Components that cross into client + components using Hydrate. Each card explains when its + client island should hydrate and keeps server-rendered HTML visible + first. +

+
+ + Server component to client Hydrate + The RSC renders a separate "use client" component that + defers hydration until interaction. + + + Composite server shellA server component owns the + visual frame while a client child hydrates only after it becomes + visible. + + + CSS module client islandA server component renders a + client Hydrate boundary whose child uses CSS modules. + +
+
+ ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx b/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx new file mode 100644 index 00000000000..f39b635241a --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/routes/server-client.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { getServerClientHydrate } from '~/server/serverHydrateComponents' + +export const Route = createFileRoute('/server-client')({ + loader: async () => ({ + Server: await getServerClientHydrate(), + }), + component: ServerClientRoute, +}) + +function ServerClientRoute() { + const { Server } = Route.useLoaderData() + return Server +} diff --git a/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx new file mode 100644 index 00000000000..d4e431061a7 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateComponents.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { createServerFn } from '@tanstack/react-start' +import { + createCompositeComponent, + renderServerComponent, +} from '@tanstack/react-start/rsc' +import { + CompositeHydrateContent, + CssModuleHydrateContent, + ServerClientHydrateContent, +} from './serverHydrateContent' + +export const getServerClientHydrate = createServerFn({ method: 'GET' }).handler( + async () => { + const renderedAt = new Date().toISOString() + + return renderServerComponent( + , + ) + }, +) + +export const getCompositeHydrate = createServerFn({ method: 'GET' }).handler( + async () => { + return createCompositeComponent((props: { children?: React.ReactNode }) => ( + {props.children} + )) + }, +) + +export const getCssModuleHydrate = createServerFn({ method: 'GET' }).handler( + async () => { + return renderServerComponent() + }, +) diff --git a/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx new file mode 100644 index 00000000000..dc785a16ce5 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/src/server/serverHydrateContent.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' +import { DeferredHydrateIsland } from '../components/DeferredHydrateIsland' +import { CssHydrateIsland } from '../components/CssHydrateIsland' + +export function ServerClientHydrateContent({ + renderedAt, +}: { + renderedAt: string +}) { + return ( +
+ React Server Component +

Server component renders a deferred client island

+

+ Server rendered at . The button below is + present in HTML but stays unhydrated until interaction. +

+ +
+ ) +} + +export function CompositeHydrateContent({ + children, +}: { + children?: React.ReactNode +}) { + return ( +
+ Composite Server Component +

Server shell, client Hydrate slot

+

+ The server owns this descriptive shell. The client slot below remains + server HTML until an interaction reaches it. +

+
+ {children} +
+ ) +} + +export function CssModuleHydrateContent() { + return ( +
+ + Server Component plus CSS module client island + +

CSS module Hydrate boundary

+

+ This server component renders a separate client component that uses + Hydrate and CSS modules. +

+ +
+ ) +} diff --git a/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts new file mode 100644 index 00000000000..2d543c5406b --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/tests/hydration.spec.ts @@ -0,0 +1,82 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { Page } from '@playwright/test' + +async function expectUnhydrated(page: Page, id: string) { + await expect(page.getByTestId(`${id}-button`)).toHaveAttribute( + 'data-hydrated', + 'false', + ) +} + +async function clickAndExpectCount(page: Page, id: string, count: string) { + await expect(page.getByTestId(`${id}-button`)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.getByTestId(`${id}-button`).click() + await expect(page.getByTestId(`${id}-count`)).toHaveText(count) +} + +async function waitForHydrateMarkerToMount(page: Page, id: string) { + await page.waitForFunction((testId) => { + const button = document.querySelector(`[data-testid="${testId}-button"]`) + const marker = button?.closest('[data-ts-hydrate-id]') + return Object.keys(marker ?? {}).some((key) => key.startsWith('__react')) + }, id) +} + +test.describe('RSC deferred hydration', () => { + test('server component renders a client Hydrate island that hydrates on interaction', async ({ + page, + }) => { + await page.goto('/server-client') + + await expect(page.getByTestId('server-client-rsc')).toContainText( + 'Server component renders a deferred client island', + ) + await expect(page.getByTestId('server-client-island')).toContainText( + 'Interaction strategy inside RSC output', + ) + await expectUnhydrated(page, 'server-client') + + await page.getByTestId('server-client-button').hover() + await clickAndExpectCount(page, 'server-client', '1') + }) + + test('composite server component can wrap an interaction Hydrate client island', async ({ + page, + }) => { + await page.goto('/composite') + + await expect(page.getByTestId('composite-rsc')).toContainText( + 'Server shell, client Hydrate slot', + ) + await expect( + page.getByTestId('composite-interaction-island'), + ).toContainText('Interaction strategy inside a composite server component') + await expectUnhydrated(page, 'composite-interaction') + + await waitForHydrateMarkerToMount(page, 'composite-interaction') + await page.getByTestId('composite-interaction-button').hover() + await clickAndExpectCount(page, 'composite-interaction', '1') + }) + + test('server component can render a CSS module Hydrate client island', async ({ + page, + }) => { + await page.goto('/css') + + await expect(page.getByTestId('css-rsc')).toContainText( + 'CSS module Hydrate boundary', + ) + await expect(page.getByTestId('css-module-marker')).toHaveCSS( + 'font-weight', + '900', + ) + await expectUnhydrated(page, 'css-nested') + await waitForHydrateMarkerToMount(page, 'css-nested') + await page.getByTestId('css-nested-button').hover() + await clickAndExpectCount(page, 'css-nested', '1') + }) +}) diff --git a/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3d1579ee87c --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.setup.ts @@ -0,0 +1,28 @@ +import { + e2eStartDummyServer, + getTestServerPort, + preOptimizeDevServer, + waitForServer, +} from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) + + if (process.env.MODE !== 'dev') return + + const port = await getTestServerPort(packageJson.name) + const baseURL = `http://localhost:${port}` + + await waitForServer(baseURL) + await preOptimizeDevServer({ + baseURL, + readyTestId: 'home-heading', + warmup: async (page) => { + for (const route of ['/server-client', '/composite', '/css']) { + await page.goto(`${baseURL}${route}`, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('networkidle') + } + }, + }) +} diff --git a/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/tests/setup/global.teardown.ts @@ -0,0 +1,6 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function teardown() { + await e2eStopDummyServer(packageJson.name) +} diff --git a/e2e/react-start/rsc-deferred-hydration/tsconfig.json b/e2e/react-start/rsc-deferred-hydration/tsconfig.json new file mode 100644 index 00000000000..cef9369516a --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2022", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true + } +} diff --git a/e2e/react-start/rsc-deferred-hydration/vite.config.ts b/e2e/react-start/rsc-deferred-hydration/vite.config.ts new file mode 100644 index 00000000000..0d3bf7a6aa7 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + server: { + port: Number(process.env.VITE_SERVER_PORT ?? 3000), + }, + plugins: [ + tanstackStart({ + rsc: { + enabled: true, + }, + }), + rsc(), + viteReact(), + ], +}) diff --git a/e2e/react-start/server-routes/src/routeTree.gen.ts b/e2e/react-start/server-routes/src/routeTree.gen.ts index 1c32040cef7..2b8bac6e096 100644 --- a/e2e/react-start/server-routes/src/routeTree.gen.ts +++ b/e2e/react-start/server-routes/src/routeTree.gen.ts @@ -16,9 +16,9 @@ import { Route as MethodsIndexRouteImport } from './routes/methods/index' import { Route as MethodsOnlyAnyRouteImport } from './routes/methods/only-any' import { Route as ApiOnlyAnyRouteImport } from './routes/api/only-any' import { Route as ApiMiddlewareContextRouteImport } from './routes/api/middleware-context' +import { Route as ApiHeadRedirectFallbackRouteImport } from './routes/api/head-redirect-fallback' import { Route as ApiHeadFallbackRouteImport } from './routes/api/head-fallback' import { Route as ApiGetAndAnyRouteImport } from './routes/api/get-and-any' -import { Route as ApiHeadRedirectFallbackRouteImport } from './routes/api/head-redirect-fallback' import { Route as ApiParamsFooRouteRouteImport } from './routes/api/params/$foo/route' import { Route as ApiParamsFooBarRouteImport } from './routes/api/params/$foo/$bar' @@ -52,6 +52,16 @@ const ApiOnlyAnyRoute = ApiOnlyAnyRouteImport.update({ path: '/api/only-any', getParentRoute: () => rootRouteImport, } as any) +const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({ + id: '/api/middleware-context', + path: '/api/middleware-context', + getParentRoute: () => rootRouteImport, +} as any) +const ApiHeadRedirectFallbackRoute = ApiHeadRedirectFallbackRouteImport.update({ + id: '/api/head-redirect-fallback', + path: '/api/head-redirect-fallback', + getParentRoute: () => rootRouteImport, +} as any) const ApiHeadFallbackRoute = ApiHeadFallbackRouteImport.update({ id: '/api/head-fallback', path: '/api/head-fallback', @@ -62,16 +72,6 @@ const ApiGetAndAnyRoute = ApiGetAndAnyRouteImport.update({ path: '/api/get-and-any', getParentRoute: () => rootRouteImport, } as any) -const ApiHeadRedirectFallbackRoute = ApiHeadRedirectFallbackRouteImport.update({ - id: '/api/head-redirect-fallback', - path: '/api/head-redirect-fallback', - getParentRoute: () => rootRouteImport, -} as any) -const ApiMiddlewareContextRoute = ApiMiddlewareContextRouteImport.update({ - id: '/api/middleware-context', - path: '/api/middleware-context', - getParentRoute: () => rootRouteImport, -} as any) const ApiParamsFooRouteRoute = ApiParamsFooRouteRouteImport.update({ id: '/api/params/$foo', path: '/api/params/$foo', @@ -87,11 +87,11 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/methods': typeof MethodsRouteRouteWithChildren '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods/': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -100,11 +100,11 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -115,11 +115,11 @@ export interface FileRoutesById { '/': typeof IndexRoute '/methods': typeof MethodsRouteRouteWithChildren '/merge-middleware-context': typeof MergeMiddlewareContextRoute - '/api/middleware-context': typeof ApiMiddlewareContextRoute - '/api/only-any': typeof ApiOnlyAnyRoute - '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/get-and-any': typeof ApiGetAndAnyRoute + '/api/head-fallback': typeof ApiHeadFallbackRoute '/api/head-redirect-fallback': typeof ApiHeadRedirectFallbackRoute + '/api/middleware-context': typeof ApiMiddlewareContextRoute + '/api/only-any': typeof ApiOnlyAnyRoute '/methods/only-any': typeof MethodsOnlyAnyRoute '/methods/': typeof MethodsIndexRoute '/api/params/$foo': typeof ApiParamsFooRouteRouteWithChildren @@ -131,11 +131,11 @@ export interface FileRouteTypes { | '/' | '/methods' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods/' | '/api/params/$foo' @@ -144,11 +144,11 @@ export interface FileRouteTypes { to: | '/' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods' | '/api/params/$foo' @@ -158,11 +158,11 @@ export interface FileRouteTypes { | '/' | '/methods' | '/merge-middleware-context' - | '/api/middleware-context' - | '/api/only-any' - | '/api/head-fallback' | '/api/get-and-any' + | '/api/head-fallback' | '/api/head-redirect-fallback' + | '/api/middleware-context' + | '/api/only-any' | '/methods/only-any' | '/methods/' | '/api/params/$foo' @@ -173,11 +173,11 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute MethodsRouteRoute: typeof MethodsRouteRouteWithChildren MergeMiddlewareContextRoute: typeof MergeMiddlewareContextRoute - ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute - ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute - ApiHeadFallbackRoute: typeof ApiHeadFallbackRoute ApiGetAndAnyRoute: typeof ApiGetAndAnyRoute + ApiHeadFallbackRoute: typeof ApiHeadFallbackRoute ApiHeadRedirectFallbackRoute: typeof ApiHeadRedirectFallbackRoute + ApiMiddlewareContextRoute: typeof ApiMiddlewareContextRoute + ApiOnlyAnyRoute: typeof ApiOnlyAnyRoute ApiParamsFooRouteRoute: typeof ApiParamsFooRouteRouteWithChildren } @@ -225,6 +225,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiOnlyAnyRouteImport parentRoute: typeof rootRouteImport } + '/api/middleware-context': { + id: '/api/middleware-context' + path: '/api/middleware-context' + fullPath: '/api/middleware-context' + preLoaderRoute: typeof ApiMiddlewareContextRouteImport + parentRoute: typeof rootRouteImport + } + '/api/head-redirect-fallback': { + id: '/api/head-redirect-fallback' + path: '/api/head-redirect-fallback' + fullPath: '/api/head-redirect-fallback' + preLoaderRoute: typeof ApiHeadRedirectFallbackRouteImport + parentRoute: typeof rootRouteImport + } '/api/head-fallback': { id: '/api/head-fallback' path: '/api/head-fallback' @@ -239,20 +253,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiGetAndAnyRouteImport parentRoute: typeof rootRouteImport } - '/api/head-redirect-fallback': { - id: '/api/head-redirect-fallback' - path: '/api/head-redirect-fallback' - fullPath: '/api/head-redirect-fallback' - preLoaderRoute: typeof ApiHeadRedirectFallbackRouteImport - parentRoute: typeof rootRouteImport - } - '/api/middleware-context': { - id: '/api/middleware-context' - path: '/api/middleware-context' - fullPath: '/api/middleware-context' - preLoaderRoute: typeof ApiMiddlewareContextRouteImport - parentRoute: typeof rootRouteImport - } '/api/params/$foo': { id: '/api/params/$foo' path: '/api/params/$foo' @@ -299,11 +299,11 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, MethodsRouteRoute: MethodsRouteRouteWithChildren, MergeMiddlewareContextRoute: MergeMiddlewareContextRoute, - ApiMiddlewareContextRoute: ApiMiddlewareContextRoute, - ApiOnlyAnyRoute: ApiOnlyAnyRoute, - ApiHeadFallbackRoute: ApiHeadFallbackRoute, ApiGetAndAnyRoute: ApiGetAndAnyRoute, + ApiHeadFallbackRoute: ApiHeadFallbackRoute, ApiHeadRedirectFallbackRoute: ApiHeadRedirectFallbackRoute, + ApiMiddlewareContextRoute: ApiMiddlewareContextRoute, + ApiOnlyAnyRoute: ApiOnlyAnyRoute, ApiParamsFooRouteRoute: ApiParamsFooRouteRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/e2e/solid-start/deferred-hydration/.gitignore b/e2e/solid-start/deferred-hydration/.gitignore new file mode 100644 index 00000000000..1b3a07ede12 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/.gitignore @@ -0,0 +1,15 @@ +node_modules +package-lock.json +yarn.lock +.DS_Store +.cache +.env +.vercel +.output +/build/ +/api/ +/server/build +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/solid-start/deferred-hydration/package.json b/e2e/solid-start/deferred-hydration/package.json new file mode 100644 index 00000000000..cf27ab349c1 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/package.json @@ -0,0 +1,54 @@ +{ + "name": "tanstack-solid-start-e2e-deferred-hydration", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "pnpm dev:vite --port 3000", + "dev:e2e": "pnpm dev:vite", + "dev:vite": "vite dev", + "dev:rsbuild": "rsbuild dev", + "build": "pnpm build:vite", + "build:vite": "vite build && tsc --noEmit", + "build:rsbuild": "rsbuild build && tsc --noEmit", + "preview": "vite preview", + "start": "node server.js", + "test:e2e": "playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/solid-router": "workspace:^", + "@tanstack/solid-start": "workspace:^", + "solid-js": "^1.9.10", + "vite": "^8.0.0" + }, + "devDependencies": { + "@rsbuild/core": "^2.0.1", + "@rsbuild/plugin-babel": "^1.1.2", + "@rsbuild/plugin-solid": "^1.1.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/node": "^22.10.2", + "rolldown": "1.0.0-rc.18", + "srvx": "^0.11.9", + "typescript": "^6.0.2", + "vite-plugin-solid": "^2.11.11" + }, + "nx": { + "targets": { + "test:e2e": { + "parallelism": false + } + }, + "metadata": { + "playwrightModes": [ + { + "toolchain": "vite", + "mode": "ssr" + }, + { + "toolchain": "rsbuild", + "mode": "ssr" + } + ] + } + } +} diff --git a/e2e/solid-start/deferred-hydration/playwright.config.ts b/e2e/solid-start/deferred-hydration/playwright.config.ts new file mode 100644 index 00000000000..c545e5c64fa --- /dev/null +++ b/e2e/solid-start/deferred-hydration/playwright.config.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs' +import { defineConfig, devices } from '@playwright/test' +import { getTestServerPort } from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' +const distDir = process.env.E2E_DIST_DIR ?? `dist-${toolchain}-ssr` +const e2ePortKey = + process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}` + +if (process.env.TEST_WORKER_INDEX === undefined) { + fs.rmSync(`port-${e2ePortKey}.txt`, { force: true }) +} + +const PORT = await getTestServerPort(e2ePortKey) +const baseURL = `http://localhost:${PORT}` + +export default defineConfig({ + testDir: './tests', + workers: 1, + reporter: [['line']], + use: { baseURL }, + webServer: { + command: `pnpm start`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + E2E_DIST_DIR: distDir, + NODE_ENV: 'production', + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/solid-start/deferred-hydration/rsbuild.config.ts b/e2e/solid-start/deferred-hydration/rsbuild.config.ts new file mode 100644 index 00000000000..7ab152bb589 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/rsbuild.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@rsbuild/core' +import { pluginBabel } from '@rsbuild/plugin-babel' +import { pluginSolid } from '@rsbuild/plugin-solid' +import { tanstackStart } from '@tanstack/solid-start/plugin/rsbuild' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist-rsbuild-ssr' + +export default defineConfig({ + plugins: [ + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + }), + pluginSolid(), + tanstackStart(), + ], + output: { + distPath: { + root: outDir, + }, + }, +}) diff --git a/e2e/solid-start/deferred-hydration/server.js b/e2e/solid-start/deferred-hydration/server.js new file mode 100644 index 00000000000..1c155e3f23b --- /dev/null +++ b/e2e/solid-start/deferred-hydration/server.js @@ -0,0 +1,99 @@ +import { createReadStream, existsSync } from 'node:fs' +import { stat } from 'node:fs/promises' +import { createServer } from 'node:http' +import path from 'node:path' +import { toNodeHandler } from 'srvx/node' + +const port = process.env.PORT || 3000 +const distDir = process.env.E2E_DIST_DIR || 'dist-vite-ssr' +const distClientDir = path.resolve(distDir, 'client') +const staticContentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', +} + +function resolveDistServerEntryPath() { + const candidates = [ + path.resolve(distDir, 'server', 'server.js'), + path.resolve(distDir, 'server', 'index.js'), + path.resolve(distDir, 'server', 'index.mjs'), + ] + + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] +} + +function getStaticFilePath(requestUrl) { + const url = new URL(requestUrl, `http://localhost:${port}`) + let decodedPath + try { + decodedPath = decodeURIComponent(url.pathname) + } catch { + return null + } + + const filePath = path.resolve(distClientDir, `.${decodedPath}`) + + if (!filePath.startsWith(`${distClientDir}${path.sep}`)) { + return null + } + + return filePath +} + +async function tryServeStatic(req, res) { + if (req.method !== 'GET' && req.method !== 'HEAD') { + return false + } + + const filePath = getStaticFilePath(req.url ?? '/') + if (!filePath) { + return false + } + + const fileStat = await stat(filePath).catch(() => null) + if (!fileStat?.isFile()) { + return false + } + + res.statusCode = 200 + res.setHeader( + 'content-type', + staticContentTypes[path.extname(filePath)] ?? 'application/octet-stream', + ) + res.setHeader('content-length', String(fileStat.size)) + + if (req.method === 'HEAD') { + res.end() + return true + } + + createReadStream(filePath).pipe(res) + return true +} + +async function createStartServer() { + const server = (await import(resolveDistServerEntryPath())).default + const nodeHandler = toNodeHandler(server.fetch) + + return createServer(async (req, res) => { + if (await tryServeStatic(req, res)) { + return + } + + await nodeHandler(req, res) + }) +} + +createStartServer().then((server) => { + server.listen(port, () => { + console.info(`Start Server: http://localhost:${port}`) + }) +}) diff --git a/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts new file mode 100644 index 00000000000..6b59f5c1ec3 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts @@ -0,0 +1,122 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as ImportedRouteImport } from './routes/imported' +import { Route as CssRouteImport } from './routes/css' +import { Route as ComponentsRouteImport } from './routes/components' +import { Route as IndexRouteImport } from './routes/index' + +const ImportedRoute = ImportedRouteImport.update({ + id: '/imported', + path: '/imported', + getParentRoute: () => rootRouteImport, +} as any) +const CssRoute = CssRouteImport.update({ + id: '/css', + path: '/css', + getParentRoute: () => rootRouteImport, +} as any) +const ComponentsRoute = ComponentsRouteImport.update({ + id: '/components', + path: '/components', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/imported': typeof ImportedRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/components' | '/css' | '/imported' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/components' | '/css' | '/imported' + id: '__root__' | '/' | '/components' | '/css' | '/imported' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ComponentsRoute: typeof ComponentsRoute + CssRoute: typeof CssRoute + ImportedRoute: typeof ImportedRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/imported': { + id: '/imported' + path: '/imported' + fullPath: '/imported' + preLoaderRoute: typeof ImportedRouteImport + parentRoute: typeof rootRouteImport + } + '/css': { + id: '/css' + path: '/css' + fullPath: '/css' + preLoaderRoute: typeof CssRouteImport + parentRoute: typeof rootRouteImport + } + '/components': { + id: '/components' + path: '/components' + fullPath: '/components' + preLoaderRoute: typeof ComponentsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + ComponentsRoute: ComponentsRoute, + CssRoute: CssRoute, + ImportedRoute: ImportedRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/solid-start' +declare module '@tanstack/solid-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/e2e/solid-start/deferred-hydration/src/router.tsx b/e2e/solid-start/deferred-hydration/src/router.tsx new file mode 100644 index 00000000000..aa7ead67524 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/router.tsx @@ -0,0 +1,9 @@ +import { createRouter } from '@tanstack/solid-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/__root.tsx b/e2e/solid-start/deferred-hydration/src/routes/__root.tsx new file mode 100644 index 00000000000..f021dbedbc6 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,92 @@ +/// +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from '@tanstack/solid-router' +import { HydrationScript } from 'solid-js/web' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'Deferred Hydration E2E' }, + ], + }), + component: RootDocument, +}) + +function RootDocument() { + return ( + + + + + + + + +
+ +
+ + + + ) +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/components.tsx b/e2e/solid-start/deferred-hydration/src/routes/components.tsx new file mode 100644 index 00000000000..9dd6387f092 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/components.tsx @@ -0,0 +1,171 @@ +import * as Solid from 'solid-js' + +import { createFileRoute } from '@tanstack/solid-router' +import { Hydrate } from '@tanstack/solid-start' +import { + condition, + idle, + interaction, + load, + media, + never, + visible, +} from '@tanstack/solid-start/hydration' + +export const Route = createFileRoute('/components')({ + component: ComponentHydrationPage, +}) + +function InteractiveBox(props: { id: string; label: string }) { + const [count, setCount] = Solid.createSignal(0) + const [hydrated, setHydrated] = Solid.createSignal(false) + + Solid.onMount(() => { + setHydrated(true) + }) + + return ( + + ) +} + +function DelayedFallbackBox() { + if (typeof window !== 'undefined') { + const [ready] = Solid.createResource(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 1000)) + return true + }) + + return ( + +
fallback child
+
+ ) + } + + return
fallback child
+} + +function ComponentHydrationPage() { + const [hydratedCallbacks, setHydratedCallbacks] = Solid.createSignal(0) + const [conditionReady, setConditionReady] = Solid.createSignal(false) + const [showClientFallbackBoundary, setShowClientFallbackBoundary] = + Solid.createSignal(false) + + return ( +
+

Component Deferred Hydration

+
+ Manual test guide + + Pink buttons are server HTML that has not hydrated yet. Green buttons + have hydrated and should increment when clicked. Follow the notes + below to trigger each strategy intentionally. + +
+

{hydratedCallbacks()}

+

+ load and idle should become green + without interaction shortly after the page loads. +

+ + + + + + +
Scroll down to reveal the visible boundary
+

+ visible hydrates only after this button enters the + viewport. +

+ + + +

+ media hydrates when (min-width: 1px) + matches. interaction hydrates on hover, focus, pointer + down, or click intent. +

+ + + + + + +

+ Custom interaction boundaries below hydrate only for their configured + events: double-click for the single-event example, and right-click or + double-click for the multi-event example. The prefetch example should + download code on hover but hydrate on click. +

+ setHydratedCallbacks((count) => count + 1)} + > + + + + + + + + + + + + + + + + + + + + + + + +

+ never stays as server HTML forever on the initial page, + so clicking should not increment it. +

+ + + client fallback + } + > + + + +
+ ) +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/css.tsx b/e2e/solid-start/deferred-hydration/src/routes/css.tsx new file mode 100644 index 00000000000..c1a80d492b9 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/css.tsx @@ -0,0 +1,57 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { Hydrate } from '@tanstack/solid-start' +import { media, never, visible } from '@tanstack/solid-start/hydration' +import outerStyles from './css/outer.module.css' +import deferredStyles from './css/deferred-only.module.css' +import sharedStyles from './css/shared.module.css' + +export const Route = createFileRoute('/css')({ + component: CssHydrationPage, +}) + +function CssHydrationPage() { + return ( +
+
+

+ CSS Deferred Hydration +

+

+ CSS from deferred, never, shared, and nested Hydrate boundaries should + be available even before the client JavaScript hydrates those islands. +

+
+
+
+ Outer CSS +
+
+ Shared outer CSS +
+
+ +
+ Deferred CSS +
+
+ +
+ Never CSS +
+
+ + +
+ Nested CSS +
+
+
+
+ ) +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css new file mode 100644 index 00000000000..05eafda03cf --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/css/deferred-only.module.css @@ -0,0 +1,15 @@ +.deferredBox { + background-color: rgb(23, 45, 67); + color: rgb(255, 255, 255); + padding: 12px; +} + +.neverBox { + color: rgb(45, 67, 89); + padding: 12px; +} + +.nestedBox { + border-left: 5px solid rgb(67, 89, 123); + padding-left: 12px; +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css new file mode 100644 index 00000000000..98ca5e0934e --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/css/outer.module.css @@ -0,0 +1,9 @@ +.heading { + color: rgb(11, 31, 53); +} + +.outerBox { + background-color: rgb(242, 250, 255); + color: rgb(12, 34, 56); + padding: 12px; +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css b/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css new file mode 100644 index 00000000000..020da5d7adc --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/css/shared.module.css @@ -0,0 +1,4 @@ +.sharedBox { + border-top: 4px solid rgb(98, 76, 54); + margin-top: 8px; +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/imported.tsx b/e2e/solid-start/deferred-hydration/src/routes/imported.tsx new file mode 100644 index 00000000000..abfb827c91a --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/imported.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/solid-router' +import { ImportedHydrateWidget } from '../shared/ImportedHydrateWidget' + +export const Route = createFileRoute('/imported')({ + component: ImportedHydrationPage, +}) + +function ImportedHydrationPage() { + return ( +
+

Imported Hydrate

+ +
+ ) +} diff --git a/e2e/solid-start/deferred-hydration/src/routes/index.tsx b/e2e/solid-start/deferred-hydration/src/routes/index.tsx new file mode 100644 index 00000000000..144afed4c61 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/index.tsx @@ -0,0 +1,21 @@ +import { Link, createFileRoute } from '@tanstack/solid-router' + +export const Route = createFileRoute('/')({ + component: Home, +}) + +function Home() { + return ( +
+

Deferred Hydration

+

Component strategies

+ component strategies +

CSS

+ CSS deferred hydration +

Imported component

+ + imported Hydrate + +
+ ) +} diff --git a/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx b/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx new file mode 100644 index 00000000000..859b7417559 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx @@ -0,0 +1,34 @@ +import { createSignal } from 'solid-js' + +import { Hydrate } from '@tanstack/solid-start' +import { media } from '@tanstack/solid-start/hydration' + +function ImportedHydrateChild() { + const [count, setCount] = createSignal(0) + + return ( + + ) +} + +export function ImportedHydrateWidget() { + return ( + + imported hydrate fallback + + } + > + + + ) +} diff --git a/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts b/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts new file mode 100644 index 00000000000..18af0d20449 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts @@ -0,0 +1,519 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import type { APIRequestContext, Page } from '@playwright/test' + +async function clickAndExpectCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(countTestId)).toHaveText(count) +} + +async function hoverIntentAndExpectCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.mouse.move(0, 0) + await page.getByTestId(buttonTestId).hover() + await clickAndExpectCount(page, buttonTestId, countTestId, count) +} + +async function dispatchHydrationIntent( + page: Page, + buttonTestId: string, + eventName: string, +) { + await page.getByTestId(buttonTestId).evaluate((element, eventName) => { + const marker = element.closest('[data-ts-hydrate-id]') + + if (!marker) { + throw new Error('Expected Hydrate marker to exist') + } + + marker.dispatchEvent( + new Event(eventName, { bubbles: true, cancelable: true }), + ) + }, eventName) +} + +async function expectRouteToStayUnhydrated( + page: Page, + buttonTestId: string, + duration = 250, +) { + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await page.waitForTimeout(duration) + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'false', + ) +} + +async function scrollToBoundary(page: Page, buttonTestId: string) { + const button = page.getByTestId(buttonTestId) + for (let attempt = 0; attempt < 3; attempt++) { + await button.evaluate((element) => { + element.scrollIntoView({ block: 'center', inline: 'nearest' }) + }) + + await page.waitForTimeout(100) + const isVisible = await button.evaluate((element) => { + const rect = element.getBoundingClientRect() + return rect.bottom > 0 && rect.top < window.innerHeight + }) + + if (isVisible) return + } + + await expect(button).toBeInViewport() +} + +async function expectCssProperty( + page: Page, + testId: string, + property: string, + value: string, +) { + await expect + .poll(() => + page.getByTestId(testId).evaluate((element, propertyName) => { + return getComputedStyle(element).getPropertyValue(propertyName) + }, property), + ) + .toBe(value) +} + +function htmlContainsText(html: string, text: string) { + const pattern = text.split(' ').join('(?:\\s|)+') + expect(html).toMatch(new RegExp(pattern)) +} + +function getModulePreloadHrefs(html: string) { + return Array.from(html.matchAll(/]*>/g), (match) => match[0]) + .filter((tag) => /\brel="modulepreload"/.test(tag)) + .map((tag) => tag.match(/\bhref="([^"]+)"/)?.[1]) + .filter((href): href is string => !!href) +} + +async function modulePreloadContentsContain( + request: APIRequestContext, + hrefs: Array, + marker: string, +) { + for (const href of hrefs) { + const response = await request.get(href) + if (!response.ok()) continue + + const text = await response.text() + if (text.includes(marker)) return true + } + + return false +} + +async function resourceContentsContain( + page: Page, + request: APIRequestContext, + marker: string, + filter: (url: string) => boolean, +) { + const resourceUrls = await page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => entry.name), + ) + + return modulePreloadContentsContain( + request, + resourceUrls.filter(filter), + marker, + ) +} + +async function documentModulePreloadHrefs(page: Page) { + return page.evaluate(() => + Array.from( + document.querySelectorAll('link[rel~="modulepreload"]'), + (link) => link.href, + ), + ) +} + +function isHydrateBoundaryResource(url: string) { + return ( + url.includes('/assets/components-') || url.includes('/static/js/async/') + ) +} + +function isClientJavaScriptResource(url: string) { + return ( + url.includes('/assets/') || + url.includes('/static/js/') || + url.includes('/static/js/async/') + ) +} + +async function expectClientRouterReady(page: Page) { + await expect + .poll(() => + page.evaluate(() => + Boolean( + ( + globalThis as typeof globalThis & { + __TSR_ROUTER__?: unknown + } + ).__TSR_ROUTER__, + ), + ), + ) + .toBe(true) +} + +test.describe('component-level Hydrate runtime strategies', () => { + test('renders SSR HTML and hydrates each runtime when appropriately', async ({ + page, + request, + }) => { + await page.goto('/components') + + await expect(page.getByTestId('component-heading')).toHaveText( + 'Component Deferred Hydration', + ) + + await clickAndExpectCount( + page, + 'component-load-button', + 'component-load-count', + '1', + ) + await clickAndExpectCount( + page, + 'component-idle-button', + 'component-idle-count', + '1', + ) + await expect( + resourceContentsContain(page, request, 'component-visible', (url) => + isHydrateBoundaryResource(url), + ), + ).resolves.toBe(false) + await expectRouteToStayUnhydrated(page, 'component-visible-button') + await scrollToBoundary(page, 'component-visible-button') + await clickAndExpectCount( + page, + 'component-visible-button', + 'component-visible-count', + '1', + ) + await expect + .poll(() => + resourceContentsContain(page, request, 'component-visible', (url) => + isHydrateBoundaryResource(url), + ), + ) + .toBe(true) + await clickAndExpectCount( + page, + 'component-media-button', + 'component-media-count', + '1', + ) + await hoverIntentAndExpectCount( + page, + 'component-interaction-button', + 'component-interaction-count', + '1', + ) + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '0', + ) + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await page.getByTestId('component-custom-single-button').hover() + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await page.getByTestId('component-custom-single-button').click() + await expectRouteToStayUnhydrated(page, 'component-custom-single-button') + await dispatchHydrationIntent( + page, + 'component-custom-single-button', + 'dblclick', + ) + await expect( + page.getByTestId('component-custom-single-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '1', + ) + await clickAndExpectCount( + page, + 'component-custom-single-button', + 'component-custom-single-count', + '1', + ) + await expect(page.getByTestId('component-on-hydrated-count')).toHaveText( + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-custom-multi-button') + await dispatchHydrationIntent( + page, + 'component-custom-multi-button', + 'contextmenu', + ) + await clickAndExpectCount( + page, + 'component-custom-multi-button', + 'component-custom-multi-count', + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-condition-button') + await page.getByTestId('component-enable-condition').click() + await clickAndExpectCount( + page, + 'component-condition-button', + 'component-condition-count', + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-click-replay-button') + await page.getByTestId('component-click-replay-button').click() + await expect( + page.getByTestId('component-click-replay-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-click-replay-count')).toHaveText( + '1', + ) + await expectRouteToStayUnhydrated(page, 'component-prefetch-button') + await expect( + resourceContentsContain(page, request, 'component-prefetch', (url) => + isHydrateBoundaryResource(url), + ), + ).resolves.toBe(false) + await page.mouse.move(0, 0) + await page.getByTestId('component-prefetch-button').hover() + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await expect + .poll(() => + resourceContentsContain(page, request, 'component-prefetch', (url) => + isHydrateBoundaryResource(url), + ), + ) + .toBe(true) + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'false', + ) + await page.getByTestId('component-prefetch-button').click() + await expect(page.getByTestId('component-prefetch-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId('component-prefetch-count')).toHaveText('1') + await hoverIntentAndExpectCount( + page, + 'component-nested-child-button', + 'component-nested-child-count', + '1', + ) + + await page.getByTestId('component-never-button').click() + await expect(page.getByTestId('component-never-count')).toHaveText('0') + }) + + test('replays click after another interaction boundary hydrates first', async ({ + page, + }) => { + await page.goto('/components') + await expectClientRouterReady(page) + + await scrollToBoundary(page, 'component-custom-multi-button') + await expectRouteToStayUnhydrated(page, 'component-custom-multi-button') + await page.getByTestId('component-custom-multi-button').click({ + button: 'right', + }) + await expect( + page.getByTestId('component-custom-multi-button'), + ).toHaveAttribute('data-hydrated', 'true') + + await scrollToBoundary(page, 'component-click-replay-button') + await expectRouteToStayUnhydrated(page, 'component-click-replay-button') + await page.getByTestId('component-click-replay-button').click() + await expect( + page.getByTestId('component-click-replay-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('component-click-replay-count')).toHaveText( + '1', + ) + }) + + test('shows fallback during a client-only mount while the child suspends', async ({ + page, + }) => { + await page.goto('/components') + await expect(page.getByTestId('component-load-button')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.getByTestId('component-show-client-fallback').click() + + await expect(page.getByTestId('component-client-fallback')).toHaveText( + 'client fallback', + ) + await expect(page.getByTestId('component-fallback-child')).toHaveText( + 'fallback child', + ) + await expect(page.getByTestId('component-client-fallback')).toHaveCount(0) + }) +}) + +test.describe('Hydrate CSS delivery', () => { + test('ships CSS for deferred, never, shared, and nested boundaries without JavaScript', async ({ + browser, + request, + }) => { + const response = await request.get('/css') + const html = await response.text() + + htmlContainsText(html, 'CSS Deferred Hydration') + htmlContainsText(html, 'Outer CSS') + htmlContainsText(html, 'Deferred CSS') + htmlContainsText(html, 'Never CSS') + htmlContainsText(html, 'Nested CSS') + + const context = await browser.newContext({ javaScriptEnabled: false }) + const page = await context.newPage() + + try { + await page.goto('/css') + + await expect(page.getByTestId('css-heading')).toHaveText( + 'CSS Deferred Hydration', + ) + await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS') + await expect(page.getByTestId('css-never')).toHaveText('Never CSS') + await expect(page.getByTestId('css-nested')).toHaveText('Nested CSS') + + await expectCssProperty(page, 'css-outer', 'color', 'rgb(12, 34, 56)') + await expectCssProperty( + page, + 'css-deferred', + 'background-color', + 'rgb(23, 45, 67)', + ) + await expectCssProperty(page, 'css-never', 'color', 'rgb(45, 67, 89)') + await expectCssProperty( + page, + 'css-shared-outer', + 'border-top-color', + 'rgb(98, 76, 54)', + ) + await expectCssProperty( + page, + 'css-deferred', + 'border-top-color', + 'rgb(98, 76, 54)', + ) + await expectCssProperty( + page, + 'css-nested', + 'border-left-color', + 'rgb(67, 89, 123)', + ) + await expectCssProperty(page, 'css-nested', 'border-left-width', '5px') + } finally { + await context.close() + } + }) + + test('renders deferred content and omits never content after client-side navigation', async ({ + page, + }) => { + await page.goto('/') + await expectClientRouterReady(page) + await page.getByRole('link', { name: 'CSS', exact: true }).click() + await expect(page).toHaveURL(/\/css$/) + + await expect(page.getByTestId('css-heading')).toHaveText( + 'CSS Deferred Hydration', + ) + await expect(page.getByTestId('css-deferred')).toHaveText('Deferred CSS') + await expect(page.getByTestId('css-never')).toHaveCount(0) + await expect(page.getByTestId('css-nested')).toHaveCount(0) + + await expectCssProperty( + page, + 'css-deferred', + 'background-color', + 'rgb(23, 45, 67)', + ) + }) +}) + +test.describe('imported Hydrate boundaries', () => { + test('does not emit filtered shared Hydrate child JS on the initial document', async ({ + request, + }) => { + const response = await request.get('/imported') + const html = await response.text() + + htmlContainsText(html, 'Imported Hydrate') + htmlContainsText(html, 'Imported Hydrate Child') + + await expect( + modulePreloadContentsContain( + request, + getModulePreloadHrefs(html), + 'imported-hydrate-child', + ), + ).resolves.toBe(false) + }) + + test('does not preload Hydrate child chunks before client navigation', async ({ + page, + request, + }) => { + await page.goto('/') + await expect(page.getByTestId('home-heading')).toHaveText( + 'Deferred Hydration', + ) + await expectClientRouterReady(page) + + const link = page.getByRole('link', { name: 'imported Hydrate' }) + await page.mouse.move(0, 0) + await link.hover() + await link.focus() + + await expect( + modulePreloadContentsContain( + request, + await documentModulePreloadHrefs(page), + 'imported-hydrate-child', + ), + ).resolves.toBe(false) + await expect( + resourceContentsContain(page, request, 'imported-hydrate-child', (url) => + isClientJavaScriptResource(url), + ), + ).resolves.toBe(false) + + await page.getByRole('link', { name: 'imported Hydrate' }).click() + await expect(page).toHaveURL(/\/imported$/) + await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0) + await expect(page.getByTestId('imported-hydrate-child')).toContainText( + 'Imported Hydrate Child', + ) + await page.getByTestId('imported-hydrate-child').click() + await expect(page.getByTestId('imported-hydrate-count')).toHaveText('1') + }) +}) diff --git a/e2e/solid-start/deferred-hydration/tsconfig.json b/e2e/solid-start/deferred-hydration/tsconfig.json new file mode 100644 index 00000000000..76cf3401fa4 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "target": "ES2024", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "~/*": ["./src/*"] + }, + "noEmit": true, + "types": ["vite/client"] + } +} diff --git a/e2e/solid-start/deferred-hydration/vite.config.ts b/e2e/solid-start/deferred-hydration/vite.config.ts new file mode 100644 index 00000000000..70f388638a8 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/solid-start/plugin/vite' +import viteSolid from 'vite-plugin-solid' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist-vite-ssr' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + build: { + outDir, + }, + server: { port: 3000 }, + plugins: [tanstackStart(), viteSolid({ ssr: true })], +}) diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 48de353b2e2..cae215bf804 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -129,6 +129,7 @@ export type { AwaitOptions } from './awaited' export { CatchBoundary, ErrorComponent } from './CatchBoundary' export { ClientOnly, useHydrated } from './ClientOnly' +export { reactUse } from './utils' export { FileRoute, diff --git a/packages/react-start-client/package.json b/packages/react-start-client/package.json index e4cf8de7d79..6981d7dee71 100644 --- a/packages/react-start-client/package.json +++ b/packages/react-start-client/package.json @@ -48,6 +48,12 @@ "default": "./dist/esm/index.js" } }, + "./hydration": { + "import": { + "types": "./dist/esm/hydration.d.ts", + "default": "./dist/esm/hydration.js" + } + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/packages/react-start-client/src/GenericHydrate.tsx b/packages/react-start-client/src/GenericHydrate.tsx new file mode 100644 index 00000000000..bac8a1716b7 --- /dev/null +++ b/packages/react-start-client/src/GenericHydrate.tsx @@ -0,0 +1,292 @@ +'use client' + +import * as React from 'react' + +import { reactUse, useHydrated } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import { + hydrateIdAttribute, + hydrateWhenAttribute, +} from '@tanstack/start-client-core/hydration/constants' +import { + createResolvedGate, + getFallbackHtml, + getOrCreateGate, + onGateResolve, + releaseGate, + saveFallbackHtml, +} from '@tanstack/start-client-core/hydration/runtime' +import type { HydrationRuntimeContext } from '@tanstack/start-client-core/hydration' +import type { HydrationGateRecord } from '@tanstack/start-client-core/hydration/runtime' +import type { HydrateProps, InternalHydrateProps } from './Hydrate' + +type Gate = HydrationGateRecord & { promise: Promise } + +function shouldDeferHydration(strategy: InternalHydrateProps['when']) { + return strategy.shouldDefer + ? strategy.shouldDefer() + : strategy.type !== 'load' +} + +function useLatest(value: T) { + const ref = React.useRef(value) + ref.current = value + return ref +} + +function runStrategyCleanup(cleanup: void | (() => void)) { + if (typeof cleanup === 'function') return cleanup + return undefined +} + +function useHydrationGate(props: InternalHydrateProps) { + const hydrated = useHydrated() + const reactId = React.useId() + const id = props.splitId ? `${props.splitId}${reactId}` : reactId + const hydrateStrategy = props.when + const latestRef = useLatest({ + hydrateStrategy, + prefetch: props.prefetch, + delegated: props.__hydrate, + preload: props.preload, + }) + const gateRef = React.useRef(undefined) + const markerElementRef = React.useRef(null) + const shouldPreserveServerHTMLRef = React.useRef( + undefined, + ) + const shouldDeferInitialHydrationRef = React.useRef( + undefined, + ) + const didPrefetchRef = React.useRef(false) + + shouldPreserveServerHTMLRef.current ??= + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + !hydrated + shouldDeferInitialHydrationRef.current ??= + !hydrated && shouldDeferHydration(hydrateStrategy) + + if (!gateRef.current) { + gateRef.current = + ((isServer as boolean | undefined) ?? typeof window === 'undefined') + ? createResolvedGate(id, hydrateStrategy.type) + : getOrCreateGate(id, hydrateStrategy.type) + } + + gateRef.current.when = hydrateStrategy.type + + if ( + !((isServer as boolean | undefined) ?? typeof window === 'undefined') && + hydrateStrategy.type !== 'never' && + (!shouldDeferInitialHydrationRef.current || + !shouldDeferHydration(hydrateStrategy)) + ) { + gateRef.current.resolve() + } + + const markerRef = React.useCallback( + (element: HTMLDivElement | null) => { + markerElementRef.current = element + if (element) { + if ( + latestRef.current.hydrateStrategy.type === 'never' && + !shouldPreserveServerHTMLRef.current + ) { + element.replaceChildren() + } + saveFallbackHtml(id, element) + } + }, + [id, latestRef], + ) + + React.useEffect(() => { + const gate = gateRef.current! + return () => { + releaseGate(gate) + } + }, []) + + React.useEffect(() => { + if ( + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + !latestRef.current.preload || + !latestRef.current.prefetch || + didPrefetchRef.current + ) { + return + } + + const prefetch = () => { + if (didPrefetchRef.current) return + didPrefetchRef.current = true + void latestRef.current.preload?.() + } + + return runStrategyCleanup( + latestRef.current.prefetch.setupPrefetch?.({ + element: markerElementRef.current, + prefetch, + }), + ) + }, [latestRef, props.prefetch?.key, props.preload]) + + React.useEffect(() => { + const gate = gateRef.current! + const { hydrateStrategy, delegated: delegatedStrategy } = latestRef.current + if ( + gate.resolved || + !shouldDeferInitialHydrationRef.current || + hydrateStrategy.type === 'never' + ) { + return + } + + const cleanups: Array<() => void> = [] + let removeResolveListener = () => {} + let disposed = false + + const cleanup = () => { + if (disposed) return + disposed = true + removeResolveListener() + cleanups.splice(0).forEach((fn) => fn()) + } + + const addCleanup = (fn: void | (() => void)) => { + if (!fn) return + if (disposed || gate.resolved) { + fn() + return + } + cleanups.push(fn) + } + + removeResolveListener = onGateResolve(gate, cleanup) + + const context: HydrationRuntimeContext = { + element: markerElementRef.current, + gate, + } + addCleanup(runStrategyCleanup(hydrateStrategy.setup?.(context))) + + if (delegatedStrategy?.setup) { + addCleanup( + runStrategyCleanup( + delegatedStrategy.setup({ + ...context, + delegated: true, + }), + ), + ) + } + + return cleanup + }, [latestRef, props.__hydrate?.key, props.when.key]) + + return { + gate: gateRef.current, + markerRef, + hydrateStrategy, + shouldPreserveServerHTML: shouldPreserveServerHTMLRef.current, + } +} + +function HydrationGate(props: { gate: Gate; children: React.ReactNode }) { + if ((isServer as boolean | undefined) ?? typeof window === 'undefined') { + return props.children as React.JSX.Element + } + + if (props.gate.resolved) { + return props.children as React.JSX.Element + } + + if (reactUse) { + reactUse(props.gate.promise) + return props.children as React.JSX.Element + } + + throw props.gate.promise +} + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: React.ReactNode +}) { + const { id, onHydrated, onStrategyHydrated } = props + const didHydrateRef = React.useRef(false) + + React.useEffect(() => { + if (didHydrateRef.current) return + didHydrateRef.current = true + onHydrated?.() + onStrategyHydrated?.(id) + }, [id, onHydrated, onStrategyHydrated]) + + return props.children as React.JSX.Element +} + +function HydrationFallback(props: { id: string }) { + const html = getFallbackHtml(props.id) + + if (!html) return null + + return ( +
+ ) +} + +export function GenericHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const { gate, hydrateStrategy, markerRef, shouldPreserveServerHTML } = + useHydrationGate(internalProps) + const fallback = shouldPreserveServerHTML ? ( + + ) : ( + (props.fallback ?? null) + ) + const markerAttributes = hydrateStrategy.getMarkerAttributes?.() + + if (hydrateStrategy.type === 'never' && !shouldPreserveServerHTML) { + return ( +
+ {props.fallback ?? null} +
+ ) + } + + return ( +
+ + + + {props.children} + + + +
+ ) +} diff --git a/packages/react-start-client/src/Hydrate.tsx b/packages/react-start-client/src/Hydrate.tsx new file mode 100644 index 00000000000..1d3e079e81e --- /dev/null +++ b/packages/react-start-client/src/Hydrate.tsx @@ -0,0 +1,53 @@ +'use client' + +import type * as React from 'react' + +import type { + HydrationStrategy as CoreHydrationStrategy, + HydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration' + +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' + +export type ReactHydrationStrategy = CoreHydrationStrategy & { + $$renderHydrate: (props: HydrateProps) => React.JSX.Element +} + +export type HydrationStrategy = ReactHydrationStrategy + +export type HydrateOptions = { + when: ReactHydrationStrategy +} + +type HydrateCommonProps = { + fallback?: React.ReactNode + onHydrated?: () => void + children: React.ReactNode +} + +export type HydrateProps = + | (HydrateCommonProps & + HydrateOptions & { + prefetch?: never + split?: boolean + }) + | (HydrateCommonProps & + HydrateOptions & { + prefetch: HydrationPrefetchStrategy + split?: true + }) + +export type InternalHydrateProps = HydrateProps & { + __hydrate?: CoreHydrationStrategy + splitId?: string + preload?: () => Promise +} + +export function Hydrate(props: HydrateProps): React.JSX.Element { + return props.when.$$renderHydrate(props) +} diff --git a/packages/react-start-client/src/hydration.ts b/packages/react-start-client/src/hydration.ts new file mode 100644 index 00000000000..ce92898f89d --- /dev/null +++ b/packages/react-start-client/src/hydration.ts @@ -0,0 +1,16 @@ +'use client' + +export { condition, interaction, media } from './hydration/generic' +export { idle } from './hydration/idle' +export { load } from './hydration/load' +export { never } from './hydration/never' +export { visible } from './hydration/visible' +export type { + HydrationCondition, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationWhen, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +export type { HydrationStrategy, ReactHydrationStrategy } from './Hydrate' diff --git a/packages/react-start-client/src/hydration/generic.ts b/packages/react-start-client/src/hydration/generic.ts new file mode 100644 index 00000000000..25b7cd08bd6 --- /dev/null +++ b/packages/react-start-client/src/hydration/generic.ts @@ -0,0 +1,38 @@ +'use client' + +import { + condition as coreCondition, + interaction as coreInteraction, + media as coreMedia, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationCondition, + HydrationInteractionEvents, + HydrationStrategy, +} from '@tanstack/start-client-core/hydration' +import type { ReactHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +function withGenericRenderer( + strategy: T, +): T & ReactHydrationStrategy { + return /* @__PURE__ */ Object.assign(strategy, { + $$renderHydrate: GenericHydrate, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function media(query: string) { + return /* @__PURE__ */ withGenericRenderer(coreMedia(query)) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function condition(condition: HydrationCondition) { + return /* @__PURE__ */ withGenericRenderer(coreCondition(condition)) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction(options?: { events?: HydrationInteractionEvents }) { + return /* @__PURE__ */ withGenericRenderer(coreInteraction(options)) +} diff --git a/packages/react-start-client/src/hydration/idle.ts b/packages/react-start-client/src/hydration/idle.ts new file mode 100644 index 00000000000..0c1ad794682 --- /dev/null +++ b/packages/react-start-client/src/hydration/idle.ts @@ -0,0 +1,15 @@ +'use client' + +import { idle as coreIdle } from '@tanstack/start-client-core/hydration' +import { StrategyHydrate } from './visible' +import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration' +import type { ReactHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function idle(options?: { + timeout?: number +}): ReactHydrationStrategy & HydrationPrefetchStrategy { + return /* @__PURE__ */ Object.assign(coreIdle(options), { + $$renderHydrate: StrategyHydrate, + }) +} diff --git a/packages/react-start-client/src/hydration/load.tsx b/packages/react-start-client/src/hydration/load.tsx new file mode 100644 index 00000000000..cd402875656 --- /dev/null +++ b/packages/react-start-client/src/hydration/load.tsx @@ -0,0 +1,69 @@ +'use client' + +import * as React from 'react' + +import { load as coreLoad } from '@tanstack/start-client-core/hydration' +import { + hydrateIdAttribute, + hydrateWhenAttribute, +} from '@tanstack/start-client-core/hydration/constants' +import type { + HydrateProps, + InternalHydrateProps, + ReactHydrationStrategy, +} from '../Hydrate' + +const loadType = 'load' + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: React.ReactNode +}) { + const { id, onHydrated, onStrategyHydrated, children } = props + const didHydrateRef = React.useRef(false) + + React.useEffect(() => { + if (didHydrateRef.current) return + didHydrateRef.current = true + onHydrated?.() + onStrategyHydrated?.(id) + }, [id, onHydrated, onStrategyHydrated]) + + return children as React.JSX.Element +} + +export function LoadHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const reactId = React.useId() + const id = internalProps.splitId + ? `${internalProps.splitId}${reactId}` + : reactId + + return ( +
+ + + {props.children} + + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): ReactHydrationStrategy { + return /* @__PURE__ */ Object.assign(coreLoad(), { + $$renderHydrate: LoadHydrate, + }) +} diff --git a/packages/react-start-client/src/hydration/never.tsx b/packages/react-start-client/src/hydration/never.tsx new file mode 100644 index 00000000000..8f66cd63f06 --- /dev/null +++ b/packages/react-start-client/src/hydration/never.tsx @@ -0,0 +1,99 @@ +'use client' + +import * as React from 'react' + +import { reactUse, useHydrated } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import { never as coreNever } from '@tanstack/start-client-core/hydration' +import { + hydrateIdAttribute, + hydrateWhenAttribute, +} from '@tanstack/start-client-core/hydration/constants' +import { + getFallbackHtml, + saveFallbackHtml, +} from '@tanstack/start-client-core/hydration/runtime' +import type { + HydrateProps, + InternalHydrateProps, + ReactHydrationStrategy, +} from '../Hydrate' + +const neverType = 'never' +const neverPromise = new Promise(() => {}) + +function NeverGate(props: { children: React.ReactNode }) { + if ((isServer as boolean | undefined) ?? typeof window === 'undefined') { + return props.children as React.JSX.Element + } + + if (reactUse) { + reactUse(neverPromise) + return props.children as React.JSX.Element + } + + throw neverPromise +} + +function HydrationFallback(props: { id: string; fallback: React.ReactNode }) { + const html = getFallbackHtml(props.id) + + if (!html) return props.fallback as React.JSX.Element | null + + return ( +
+ ) +} + +export function NeverHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const hydrated = useHydrated() + const reactId = React.useId() + const id = internalProps.splitId + ? `${internalProps.splitId}${reactId}` + : reactId + const shouldPreserveServerHTMLRef = React.useRef( + undefined, + ) + shouldPreserveServerHTMLRef.current ??= + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + !hydrated + const markerRef = React.useCallback( + (element: HTMLDivElement | null) => { + if (!element) return + if (!shouldPreserveServerHTMLRef.current) { + element.replaceChildren() + } else { + saveFallbackHtml(id, element) + } + }, + [id], + ) + const markerProps = { + ref: markerRef, + [hydrateIdAttribute]: id, + [hydrateWhenAttribute]: neverType, + } + + return ( +
+ + } + > + {props.children} + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function never(): ReactHydrationStrategy { + return /* @__PURE__ */ Object.assign(coreNever(), { + $$renderHydrate: NeverHydrate, + }) +} diff --git a/packages/react-start-client/src/hydration/visible.tsx b/packages/react-start-client/src/hydration/visible.tsx new file mode 100644 index 00000000000..98b39d6f9c7 --- /dev/null +++ b/packages/react-start-client/src/hydration/visible.tsx @@ -0,0 +1,185 @@ +'use client' + +import * as React from 'react' + +import { reactUse } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants' +import type { + HydrationPrefetchStrategy, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { + HydrateProps, + InternalHydrateProps, + ReactHydrationStrategy, +} from '../Hydrate' + +const visibleType = 'visible' + +type VisibleGate = { + promise: Promise + resolved: boolean + resolve: () => void +} + +function observeVisible( + element: Element | null, + callback: () => void, + rootMargin: string, + threshold: number | Array, +) { + if (!element || typeof IntersectionObserver !== 'function') { + callback() + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + observer.disconnect() + callback() + }, + { rootMargin, threshold }, + ) + observer.observe(element) + return () => observer.disconnect() +} + +function createGate() { + let resolvePromise!: () => void + const gate: VisibleGate = { + promise: new Promise((resolve) => { + resolvePromise = resolve + }), + resolved: false, + resolve: () => { + if (gate.resolved) return + gate.resolved = true + resolvePromise() + }, + } + return gate +} + +function HydrationGate(props: { + gate: VisibleGate + children: React.ReactNode +}) { + if (props.gate.resolved) return props.children as React.JSX.Element + + if (reactUse) { + reactUse(props.gate.promise) + return props.children as React.JSX.Element + } + + throw props.gate.promise +} + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: React.ReactNode +}) { + const { id, onHydrated, onStrategyHydrated, children } = props + const didHydrateRef = React.useRef(false) + + React.useEffect(() => { + if (didHydrateRef.current) return + didHydrateRef.current = true + onHydrated?.() + onStrategyHydrated?.(id) + }, [id, onHydrated, onStrategyHydrated]) + + return children as React.JSX.Element +} + +export function StrategyHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const reactId = React.useId() + const id = internalProps.splitId + ? `${internalProps.splitId}${reactId}` + : reactId + const strategy = internalProps.when + const prefetchStrategy = internalProps.prefetch + const preload = internalProps.preload + const markerRef = React.useRef(null) + const didPrefetchRef = React.useRef(false) + const gateRef = React.useRef(undefined) + + if (!gateRef.current) { + gateRef.current = createGate() + if ((isServer as boolean | undefined) ?? typeof window === 'undefined') { + gateRef.current.resolve() + } + } + + React.useEffect(() => { + if (!preload || !prefetchStrategy || didPrefetchRef.current) return + + const prefetch = () => { + if (didPrefetchRef.current) return + didPrefetchRef.current = true + void preload() + } + + const cleanup = prefetchStrategy.setupPrefetch?.({ + element: markerRef.current, + prefetch, + }) + if (typeof cleanup === 'function') return cleanup + return undefined + }, [prefetchStrategy, preload]) + + React.useEffect(() => { + const gate = gateRef.current! + if (gate.resolved) return + + const cleanup = strategy.setup?.({ + element: markerRef.current, + gate: gate as any, + }) + if (typeof cleanup === 'function') return cleanup + return undefined + }, [strategy]) + + return ( +
+ + + + {props.children} + + + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options?: VisibleHydrationOptions, +): ReactHydrationStrategy & HydrationPrefetchStrategy { + const rootMargin = options?.rootMargin ?? '600px' + const threshold = options?.threshold ?? 0 + + return { + type: visibleType, + key: visibleType, + setup: ({ element, gate }: any) => + observeVisible(element, gate.resolve, rootMargin, threshold), + setupPrefetch: ({ element, prefetch }: any) => + observeVisible(element, prefetch, rootMargin, threshold), + $$renderHydrate: StrategyHydrate, + } +} diff --git a/packages/react-start-client/src/index.tsx b/packages/react-start-client/src/index.tsx index aa73990a576..1f11a00a897 100644 --- a/packages/react-start-client/src/index.tsx +++ b/packages/react-start-client/src/index.tsx @@ -1,2 +1,15 @@ +'use client' + export { StartClient } from './StartClient' export { hydrateStart } from './hydrateStart' +export { Hydrate } from './Hydrate' +export { lazyHydratedComponent } from './lazyHydratedComponent' +export type { + HydrateOptions, + HydrateProps, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, +} from './Hydrate' diff --git a/packages/react-start-client/src/lazyHydratedComponent.tsx b/packages/react-start-client/src/lazyHydratedComponent.tsx new file mode 100644 index 00000000000..8d886b9d03f --- /dev/null +++ b/packages/react-start-client/src/lazyHydratedComponent.tsx @@ -0,0 +1,9 @@ +import { lazyRouteComponent } from '@tanstack/react-router' +import type { AsyncRouteComponent } from '@tanstack/react-router' + +export function lazyHydratedComponent( + importer: () => Promise, + exportName: string, +): AsyncRouteComponent { + return lazyRouteComponent(importer, exportName) +} diff --git a/packages/react-start-client/src/tests/Hydrate.test-d.tsx b/packages/react-start-client/src/tests/Hydrate.test-d.tsx new file mode 100644 index 00000000000..8476cc1e8b8 --- /dev/null +++ b/packages/react-start-client/src/tests/Hydrate.test-d.tsx @@ -0,0 +1,85 @@ +import { expectTypeOf, test } from 'vitest' +import { Hydrate } from '../Hydrate' +import type { + HydrateProps, + HydrationPrefetchStrategy, + HydrationStrategy, +} from '../Hydrate' +import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration' +import type { visible } from '../hydration' +import type { ReactNode } from 'react' + +type CommonHydrateProps = { + fallback?: ReactNode + onHydrated?: () => void + children: ReactNode +} + +type SplitHydrateProps = CommonHydrateProps & { + when: HydrationStrategy + prefetch?: never + split?: boolean +} + +type PrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split?: true +} + +test('Hydrate component accepts the public HydrateProps type', () => { + expectTypeOf(Hydrate).toBeFunction() + expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf() +}) + +test('Hydrate props are exact for strategy and prefetch forms', () => { + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() +}) + +test('Hydrate requires a strategy', () => { + expectTypeOf<{ + when: HydrationStrategy + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + children: ReactNode + }>().not.toMatchTypeOf() +}) + +test('Hydrate requires a framework-renderable strategy', () => { + expectTypeOf().not.toMatchTypeOf() + expectTypeOf>().toMatchTypeOf() + + expectTypeOf<{ + when: CoreHydrationStrategy + children: ReactNode + }>().not.toMatchTypeOf() +}) + +test('Hydrate enforces prefetch only with split boundaries', () => { + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split: true + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split: false + children: ReactNode + }>().not.toMatchTypeOf() +}) diff --git a/packages/react-start-client/src/tests/Hydrate.test.tsx b/packages/react-start-client/src/tests/Hydrate.test.tsx new file mode 100644 index 00000000000..87d2df915a2 --- /dev/null +++ b/packages/react-start-client/src/tests/Hydrate.test.tsx @@ -0,0 +1,396 @@ +import * as React from 'react' +import { renderToString } from 'react-dom/server' +import { hydrateRoot } from 'react-dom/client' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants' +import { Hydrate } from '../Hydrate' +import { idle, interaction, load, never } from '../hydration' +import type { HydrateProps } from '../Hydrate' + +const InternalHydrate = Hydrate as React.ComponentType< + HydrateProps & { preload?: () => Promise; splitId?: string } +> + +const hydrateIdSelector = `[${hydrateIdAttribute}]` + +function getMarker() { + const marker = document.querySelector(hydrateIdSelector) + + if (!marker) { + throw new Error('Expected Hydrate marker to exist') + } + + return marker +} + +function InteractiveChild() { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +function NamedInteractiveChild(props: { id: string }) { + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +function createSuspendingChild() { + let resolve!: () => void + let resolved = false + const promise = new Promise((resolvePromise) => { + resolve = () => { + resolved = true + resolvePromise() + } + }) + + function SuspendingChild() { + if (!resolved) { + throw promise + } + + return
child
+ } + + return { resolve, SuspendingChild } +} + +async function expectNoHydrationAfterDefaultIntentEvents() { + const marker = getMarker() + + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + + await act(async () => { + fireEvent.pointerEnter(marker) + fireEvent.focusIn(marker) + fireEvent.pointerDown(marker) + fireEvent.click(marker) + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) +} + +async function fireIntent(event: () => void) { + await act(async () => { + event() + await Promise.resolve() + }) +} + +async function renderAsync(ui: React.ReactElement) { + await act(async () => { + render(ui) + await Promise.resolve() + }) +} + +async function hydrateFromServer(ui: React.ReactElement) { + vi.stubGlobal('window', undefined) + const html = renderToString(ui) + vi.unstubAllGlobals() + + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = html + + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, ui) + await Promise.resolve() + }) + + return { container, html, root } +} + +async function unmountHydratedRoot( + root: ReturnType, + container: Element, +) { + await act(async () => { + root.unmount() + }) + container.remove() +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('Hydrate', () => { + it('uses a single custom interaction event instead of the default intent events', async () => { + const { container, html, root } = await hydrateFromServer( + fallback
} + > + + , + ) + + try { + expect(html).toContain('data-testid="child"') + expect(html).not.toContain('data-testid="fallback"') + expect(screen.queryByTestId('fallback')).toBeNull() + await expectNoHydrationAfterDefaultIntentEvents() + + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('uses every event in a custom interaction event list', async () => { + const { container, root } = await hydrateFromServer( + fallback
} + > + + , + ) + + try { + expect(screen.queryByTestId('fallback')).toBeNull() + await expectNoHydrationAfterDefaultIntentEvents() + + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('omits never content when mounted after the app is already hydrated', async () => { + await renderAsync( + + + , + ) + + expect(screen.queryByTestId('child')).toBeNull() + }) + + it('shows fallback for a client-only mount while children suspend', async () => { + const { resolve, SuspendingChild } = createSuspendingChild() + + await renderAsync( + fallback} + > + + , + ) + + expect(screen.getByTestId('fallback').textContent).toBe('fallback') + expect(screen.queryByTestId('child')).toBeNull() + + await act(async () => { + resolve() + await Promise.resolve() + }) + + await screen.findByTestId('child') + expect(screen.queryByTestId('fallback')).toBeNull() + }) + + it('does not use fallback for an initial never boundary', async () => { + const { container, html, root } = await hydrateFromServer( + fallback} + > + + , + ) + + try { + expect(html).toContain('data-testid="child"') + expect(html).not.toContain('data-testid="fallback"') + expect(screen.queryByTestId('fallback')).toBeNull() + + fireEvent.click(screen.getByTestId('child')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + expect(screen.getByTestId('child').textContent).toBe('0') + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('keeps repeated split boundaries independently gated', async () => { + const { container, root } = await hydrateFromServer( + <> + + + + + + + , + ) + + try { + const markers = container.querySelectorAll(hydrateIdSelector) + + expect(markers).toHaveLength(2) + expect(markers[0]!.getAttribute(hydrateIdAttribute)).not.toBe( + markers[1]!.getAttribute(hydrateIdAttribute), + ) + expect( + screen.getByTestId('child-one').getAttribute('data-hydrated'), + ).toBe('false') + expect( + screen.getByTestId('child-two').getAttribute('data-hydrated'), + ).toBe('false') + + await fireIntent(() => + markers[0]!.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect( + screen.getByTestId('child-one').getAttribute('data-hydrated'), + ).toBe('true'), + ) + expect( + screen.getByTestId('child-two').getAttribute('data-hydrated'), + ).toBe('false') + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('fires onHydrated once after the client hydration commit', async () => { + const onHydrated = vi.fn() + const app = ( + +
child
+
+ ) + + vi.stubGlobal('window', undefined) + const html = renderToString(app) + expect(html).toContain('child') + expect(onHydrated).not.toHaveBeenCalled() + vi.unstubAllGlobals() + + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = html + + let root!: ReturnType + await act(async () => { + root = hydrateRoot(container, app) + }) + + await waitFor(() => expect(onHydrated).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByTestId('child')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(onHydrated).toHaveBeenCalledTimes(1) + + await act(async () => { + root.unmount() + }) + container.remove() + }) + + it('prefetches split children without hydrating the boundary', async () => { + const preload = vi.fn(() => Promise.resolve()) + + const { container, root } = await hydrateFromServer( + + + , + ) + + try { + await waitFor(() => expect(preload).toHaveBeenCalledTimes(1)) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + expect(preload).toHaveBeenCalledTimes(1) + } finally { + await unmountHydratedRoot(root, container) + } + }) +}) diff --git a/packages/react-start-client/vite.config.ts b/packages/react-start-client/vite.config.ts index 32119eee3af..40e07174ac0 100644 --- a/packages/react-start-client/vite.config.ts +++ b/packages/react-start-client/vite.config.ts @@ -20,7 +20,7 @@ export default mergeConfig( tanstackViteConfig({ tsconfigPath: './tsconfig.build.json', srcDir: './src', - entry: './src/index.tsx', + entry: ['./src/index.tsx', './src/hydration.ts'], cjs: false, }), ) diff --git a/packages/react-start/package.json b/packages/react-start/package.json index fd7055236c7..2aef9a6fdb2 100644 --- a/packages/react-start/package.json +++ b/packages/react-start/package.json @@ -48,6 +48,12 @@ "default": "./dist/esm/client.js" } }, + "./hydration": { + "import": { + "types": "./dist/esm/hydration.d.ts", + "default": "./dist/esm/hydration.js" + } + }, "./client-rpc": { "import": { "types": "./dist/esm/client-rpc.d.ts", diff --git a/packages/react-start/src/hydration.ts b/packages/react-start/src/hydration.ts new file mode 100644 index 00000000000..e86169e71dc --- /dev/null +++ b/packages/react-start/src/hydration.ts @@ -0,0 +1,18 @@ +export { + condition, + idle, + interaction, + load, + media, + never, + visible, +} from '@tanstack/react-start-client/hydration' +export type { + HydrationCondition, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, + VisibleHydrationOptions, +} from '@tanstack/react-start-client/hydration' diff --git a/packages/react-start/src/index.ts b/packages/react-start/src/index.ts index 8b51b6c7832..417acf20864 100644 --- a/packages/react-start/src/index.ts +++ b/packages/react-start/src/index.ts @@ -1,2 +1,13 @@ export { useServerFn } from './useServerFn' export * from '@tanstack/start-client-core' +export { createServerFn } from '@tanstack/start-client-core' +export { Hydrate, lazyHydratedComponent } from '@tanstack/react-start-client' +export type { + HydrateOptions, + HydrateProps, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, +} from '@tanstack/react-start-client' diff --git a/packages/react-start/vite.config.ts b/packages/react-start/vite.config.ts index 0995b1f9b46..652e4ef8425 100644 --- a/packages/react-start/vite.config.ts +++ b/packages/react-start/vite.config.ts @@ -27,6 +27,7 @@ export default mergeConfig( entry: [ './src/index.ts', './src/client.tsx', + './src/hydration.ts', './src/client-rpc.ts', './src/server.tsx', './src/server.rsc.ts', diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index ded15fff6df..e737fa50f8a 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -185,6 +185,7 @@ export type { RouteContextFn, ContextOptions, RouteContextOptions, + SsrContextOptions, BeforeLoadContextOptions, RootRouteOptions, RootRouteOptionsExtensions, diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index 2bca7009d9e..c1e467f2fbf 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -292,23 +292,27 @@ export function attachRouterServerSsrUtils({ }) { router.ssr = { get manifest() { + if (!manifest) return manifest + const requestAssets = getRequestAssets?.() - const inlineCssAsset = getInlineCssAssetForMatches( - manifest, - router.stores.matches.get(), - ) - if (!requestAssets?.length && !inlineCssAsset) return manifest + const matches = router.stores.matches.get() + const inlineCssAsset = getInlineCssAssetForMatches(manifest, matches) + + if (!requestAssets?.length && !inlineCssAsset) { + return manifest + } + // Merge request-scoped assets into root route without mutating cached manifest return { ...manifest, routes: { - ...manifest?.routes, + ...manifest.routes, [rootRouteId]: { - ...manifest?.routes?.[rootRouteId], + ...manifest.routes[rootRouteId], assets: [ ...(requestAssets ?? []), ...(inlineCssAsset ? [inlineCssAsset] : []), - ...(manifest?.routes?.[rootRouteId]?.assets ?? []), + ...(manifest.routes[rootRouteId]?.assets ?? []), ], }, }, diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts index d59b4fa9afb..32eade99af1 100644 --- a/packages/router-plugin/src/core/code-splitter/compilers.ts +++ b/packages/router-plugin/src/core/code-splitter/compilers.ts @@ -2,15 +2,27 @@ import * as t from '@babel/types' import * as babel from '@babel/core' import * as template from '@babel/template' import { + buildDeclarationMap, + buildDependencyGraph, + collectIdentifiersFromPattern, + collectLocalBindingsFromStatement, + collectModuleLevelRefsFromNode, + createIdentifier, deadCodeElimination, + expandDestructuredDeclarations, + expandSharedDestructuredDeclarators, + expandTransitively, findReferencedIdentifiers, generateFromAst, parseAst, + removeBindingsTransitivelyDependingOn, + retainModuleLevelDeclarations, + stripUnreferencedTopLevelExpressionStatements, + unwrapExportedDeclarations, } from '@tanstack/router-utils' import { tsrShared, tsrSplit } from '../constants' import { createRouteHmrStatement } from '../hmr' import { getObjectPropertyKeyName } from '../utils' -import { createIdentifier } from './path-ids' import { getFrameworkOptions } from './framework-options' import type { CompileCodeSplitReferenceRouteOptions, @@ -20,6 +32,25 @@ import type { GeneratorResult, ParseAstOptions } from '@tanstack/router-utils' import type { CodeSplitGroupings, SplitRouteIdentNodes } from '../constants' import type { SplitNodeMeta } from './types' +export { + buildDeclarationMap, + buildDependencyGraph, + collectIdentifiersFromNode, + collectLocalBindingsFromStatement, + collectModuleLevelRefsFromNode, + expandDestructuredDeclarations, + expandSharedDestructuredDeclarators, + expandTransitively, + removeBindingsTransitivelyDependingOn, +} from '@tanstack/router-utils' + +export function removeBindingsDependingOnRoute( + bindings: Set, + dependencyGraph: Map>, +) { + removeBindingsTransitivelyDependingOn(bindings, dependencyGraph, ['Route']) +} + const SPLIT_NODES_CONFIG = new Map([ [ 'loader', @@ -108,124 +139,6 @@ const allCreateRouteFns = [ ...unsplittableCreateRouteFns, ] -/** - * Recursively walk an AST node and collect referenced identifier-like names. - * Much cheaper than babel.traverse — no path/scope overhead. - * - * Notes: - * - Uses @babel/types `isReferenced` to avoid collecting non-references like - * object keys, member expression properties, or binding identifiers. - * - Also handles JSX identifiers for component references. - */ -export function collectIdentifiersFromNode(node: t.Node): Set { - const ids = new Set() - - ;(function walk( - n: t.Node | null | undefined, - parent?: t.Node, - grandparent?: t.Node, - parentKey?: string, - ) { - if (!n) return - - if (t.isIdentifier(n)) { - // When we don't have parent info (node passed in isolation), treat as referenced. - if (!parent || t.isReferenced(n, parent, grandparent)) { - ids.add(n.name) - } - return - } - - if (t.isJSXIdentifier(n)) { - // Skip attribute names:
- if (parent && t.isJSXAttribute(parent) && parentKey === 'name') { - return - } - - // Skip member properties: should count Foo, not Bar - if ( - parent && - t.isJSXMemberExpression(parent) && - parentKey === 'property' - ) { - return - } - - // Intrinsic elements (lowercase) are not identifiers - const first = n.name[0] - if (first && first === first.toLowerCase()) { - return - } - - ids.add(n.name) - return - } - - for (const key of t.VISITOR_KEYS[n.type] || []) { - const child = (n as any)[key] - if (Array.isArray(child)) { - for (const c of child) { - if (c && typeof c.type === 'string') { - walk(c, n, parent, key) - } - } - } else if (child && typeof child.type === 'string') { - walk(child, n, parent, key) - } - } - })(node) - - return ids -} - -/** - * Build a map from binding name → declaration AST node for all - * locally-declared module-level bindings. Built once, O(1) lookup. - */ -export function buildDeclarationMap(ast: t.File): Map { - const map = new Map() - for (const stmt of ast.program.body) { - const decl = - t.isExportNamedDeclaration(stmt) && stmt.declaration - ? stmt.declaration - : stmt - - if (t.isVariableDeclaration(decl)) { - for (const declarator of decl.declarations) { - for (const name of collectIdentifiersFromPattern(declarator.id)) { - map.set(name, declarator) - } - } - } else if (t.isFunctionDeclaration(decl) && decl.id) { - map.set(decl.id.name, decl) - } else if (t.isClassDeclaration(decl) && decl.id) { - map.set(decl.id.name, decl) - } - } - return map -} - -/** - * Build a dependency graph: for each local binding, the set of other local - * bindings its declaration references. Built once via simple node walking. - */ -export function buildDependencyGraph( - declMap: Map, - localBindings: Set, -): Map> { - const graph = new Map>() - for (const [name, declNode] of declMap) { - if (!localBindings.has(name)) continue - const allIds = collectIdentifiersFromNode(declNode) - const deps = new Set() - for (const id of allIds) { - if (id !== name && localBindings.has(id)) deps.add(id) - } - graph.set(name, deps) - } - return graph -} - /** * Computes module-level bindings that are shared between split and non-split * route properties. These bindings need to be extracted into a shared virtual @@ -381,199 +294,11 @@ export function computeSharedBindings(opts: { // Remove shared bindings that transitively depend on `Route`. // The Route singleton must stay in the reference file; extracting a // binding that references it would duplicate Route in the shared module. - removeBindingsDependingOnRoute(shared, fullDepGraph) + removeBindingsTransitivelyDependingOn(shared, fullDepGraph, ['Route']) return shared } -/** - * If bindings from the same destructured declarator are referenced by - * different groups, mark all bindings from that declarator as shared. - */ -export function expandSharedDestructuredDeclarators( - ast: t.File, - refsByGroup: Map>, - shared: Set, -) { - for (const stmt of ast.program.body) { - const decl = - t.isExportNamedDeclaration(stmt) && stmt.declaration - ? stmt.declaration - : stmt - - if (!t.isVariableDeclaration(decl)) continue - - for (const declarator of decl.declarations) { - if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id)) - continue - - const names = collectIdentifiersFromPattern(declarator.id) - - const usedGroups = new Set() - for (const name of names) { - const groups = refsByGroup.get(name) - if (!groups) continue - for (const g of groups) usedGroups.add(g) - } - - if (usedGroups.size >= 2) { - for (const name of names) { - shared.add(name) - } - } - } - } -} - -/** - * Collect locally-declared module-level binding names from a statement. - * Pure node inspection, no traversal. - */ -export function collectLocalBindingsFromStatement( - node: t.Statement | t.ModuleDeclaration, - bindings: Set, -) { - const decl = - t.isExportNamedDeclaration(node) && node.declaration - ? node.declaration - : node - - if (t.isVariableDeclaration(decl)) { - for (const declarator of decl.declarations) { - for (const name of collectIdentifiersFromPattern(declarator.id)) { - bindings.add(name) - } - } - } else if (t.isFunctionDeclaration(decl) && decl.id) { - bindings.add(decl.id.name) - } else if (t.isClassDeclaration(decl) && decl.id) { - bindings.add(decl.id.name) - } -} - -/** - * Collect direct module-level binding names referenced from a given AST node. - * Uses a simple recursive walk instead of babel.traverse. - */ -export function collectModuleLevelRefsFromNode( - node: t.Node, - localModuleLevelBindings: Set, -): Set { - const allIds = collectIdentifiersFromNode(node) - const refs = new Set() - for (const name of allIds) { - if (localModuleLevelBindings.has(name)) refs.add(name) - } - return refs -} - -/** - * Expand the shared set transitively using a prebuilt dependency graph. - * No AST traversals — pure graph BFS. - */ -export function expandTransitively( - shared: Set, - depGraph: Map>, -) { - const queue = [...shared] - const visited = new Set() - - while (queue.length > 0) { - const name = queue.pop()! - if (visited.has(name)) continue - visited.add(name) - - const deps = depGraph.get(name) - if (!deps) continue - - for (const dep of deps) { - if (!shared.has(dep)) { - shared.add(dep) - queue.push(dep) - } - } - } -} - -/** - * Remove any bindings from `shared` that transitively depend on `Route`. - * The Route singleton must remain in the reference file; if a shared binding - * references it (directly or transitively), extracting that binding would - * duplicate Route in the shared module. - * - * Uses `depGraph` which must include `Route` as a node so the dependency - * chain is visible. - */ -export function removeBindingsDependingOnRoute( - shared: Set, - depGraph: Map>, -) { - const reverseGraph = new Map>() - for (const [name, deps] of depGraph) { - for (const dep of deps) { - let parents = reverseGraph.get(dep) - if (!parents) { - parents = new Set() - reverseGraph.set(dep, parents) - } - parents.add(name) - } - } - - // Walk backwards from Route to find all bindings that can reach it. - const visited = new Set() - const queue = ['Route'] - while (queue.length > 0) { - const cur = queue.pop()! - if (visited.has(cur)) continue - visited.add(cur) - - const parents = reverseGraph.get(cur) - if (!parents) continue - for (const parent of parents) { - if (!visited.has(parent)) queue.push(parent) - } - } - - for (const name of [...shared]) { - if (visited.has(name)) { - shared.delete(name) - } - } -} - -/** - * If any binding from a destructured declaration is shared, - * ensure all bindings from that same declaration are also shared. - * Pure node inspection of program.body, no traversal. - */ -export function expandDestructuredDeclarations( - ast: t.File, - shared: Set, -) { - for (const stmt of ast.program.body) { - const decl = - t.isExportNamedDeclaration(stmt) && stmt.declaration - ? stmt.declaration - : stmt - - if (!t.isVariableDeclaration(decl)) continue - - for (const declarator of decl.declarations) { - if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id)) - continue - - const names = collectIdentifiersFromPattern(declarator.id) - const hasShared = names.some((n) => shared.has(n)) - if (hasShared) { - for (const n of names) { - shared.add(n) - } - } - } - } -} - /** * Find which shared bindings are user-exported in the original source. * These need to be re-exported from the shared module. @@ -740,6 +465,21 @@ export function compileCodeSplitReferenceRoute( if (t.isObjectExpression(routeOptions)) { const insertionPath = path.getStatementParent() ?? path + opts.compilerPlugins?.forEach((plugin) => { + const pluginResult = plugin.onRouteOptions?.({ + programPath, + callExpressionPath: path, + insertionPath, + routeOptions, + createRouteFn, + opts: opts as CompileCodeSplitReferenceRouteOptions, + }) + + if (pluginResult?.modified) { + modified = true + } + }) + if (opts.deleteNodes && opts.deleteNodes.size > 0) { routeOptions.properties = routeOptions.properties.filter( (prop) => { @@ -1525,22 +1265,7 @@ export function compileCodeSplitVirtualRoute( }) deadCodeElimination(ast, refIdents) - - // Strip top-level expression statements that reference no locally-bound names. - // DCE only removes unused declarations; bare side-effect statements like - // `console.log(...)` survive even when the virtual file has no exports. - { - const locallyBound = new Set() - for (const stmt of ast.program.body) { - collectLocalBindingsFromStatement(stmt, locallyBound) - } - ast.program.body = ast.program.body.filter((stmt) => { - if (!t.isExpressionStatement(stmt)) return true - const refs = collectIdentifiersFromNode(stmt) - // Keep if it references at least one locally-bound identifier - return [...refs].some((name) => locallyBound.has(name)) - }) - } + stripUnreferencedTopLevelExpressionStatements(ast) // If the body is empty after DCE, strip directive prologues too. // A file containing only `'use client'` with no real code is useless. @@ -1595,49 +1320,8 @@ export function compileCodeSplitSharedRoute( keepBindings.delete('Route') expandTransitively(keepBindings, depGraph) - // Remove all statements except: - // - Import declarations (needed for deps; DCE will clean unused ones) - // - Declarations of bindings in keepBindings - ast.program.body = ast.program.body.filter((stmt) => { - // Always keep imports — DCE will remove unused ones - if (t.isImportDeclaration(stmt)) return true - - const decl = - t.isExportNamedDeclaration(stmt) && stmt.declaration - ? stmt.declaration - : stmt - - if (t.isVariableDeclaration(decl)) { - // Keep declarators where at least one binding is in keepBindings - decl.declarations = decl.declarations.filter((declarator) => { - const names = collectIdentifiersFromPattern(declarator.id) - return names.some((n) => keepBindings.has(n)) - }) - if (decl.declarations.length === 0) return false - - // Strip the `export` wrapper — shared module controls its own exports - if (t.isExportNamedDeclaration(stmt) && stmt.declaration) { - return true // keep for now, we'll convert below - } - return true - } else if (t.isFunctionDeclaration(decl) && decl.id) { - return keepBindings.has(decl.id.name) - } else if (t.isClassDeclaration(decl) && decl.id) { - return keepBindings.has(decl.id.name) - } - - // Remove everything else (expression statements, other exports, etc.) - return false - }) - - // Convert `export const/function/class` to plain declarations - // (we'll add our own export statement at the end) - ast.program.body = ast.program.body.map((stmt) => { - if (t.isExportNamedDeclaration(stmt) && stmt.declaration) { - return stmt.declaration - } - return stmt - }) + retainModuleLevelDeclarations(ast, keepBindings) + unwrapExportedDeclarations(ast) // Export all shared bindings (sorted for deterministic output) const exportNames = [...opts.sharedBindings].sort((a, b) => @@ -1837,50 +1521,6 @@ function getImportSpecifierAndPathFromLocalName( return { specifier, path } } -/** - * Recursively collects all identifier names from a destructuring pattern - * (ObjectPattern, ArrayPattern, AssignmentPattern, RestElement). - */ -function collectIdentifiersFromPattern( - node: t.LVal | t.Node | null | undefined, -): Array { - if (!node) { - return [] - } - - if (t.isIdentifier(node)) { - return [node.name] - } - - if (t.isAssignmentPattern(node)) { - return collectIdentifiersFromPattern(node.left) - } - - if (t.isRestElement(node)) { - return collectIdentifiersFromPattern(node.argument) - } - - if (t.isObjectPattern(node)) { - return node.properties.flatMap((prop) => { - if (t.isObjectProperty(prop)) { - return collectIdentifiersFromPattern(prop.value as t.LVal) - } - if (t.isRestElement(prop)) { - return collectIdentifiersFromPattern(prop.argument) - } - return [] - }) - } - - if (t.isArrayPattern(node)) { - return node.elements.flatMap((element) => - collectIdentifiersFromPattern(element), - ) - } - - return [] -} - // Reusable function to get literal value or resolve variable to literal function resolveIdentifier(path: any, node: any): t.Node | undefined { if (t.isIdentifier(node)) { diff --git a/packages/router-plugin/src/core/code-splitter/plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins.ts index 4b2363e3615..ebf1f4aa305 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins.ts @@ -43,6 +43,9 @@ export type ReferenceRouteCompilerPluginResult = { export type ReferenceRouteCompilerPlugin = { name: string getStableRouteOptionKeys?: () => Array + onRouteOptions?: ( + ctx: ReferenceRouteCompilerPluginContext, + ) => void | ReferenceRouteCompilerPluginResult onAddHmr?: ( ctx: ReferenceRouteCompilerPluginContext, ) => void | ReferenceRouteCompilerPluginResult diff --git a/packages/router-plugin/src/core/config.ts b/packages/router-plugin/src/core/config.ts index f764bfe8bb8..d1d36bbe370 100644 --- a/packages/router-plugin/src/core/config.ts +++ b/packages/router-plugin/src/core/config.ts @@ -9,6 +9,7 @@ import type { RouteIds, } from '@tanstack/router-core' import type { CodeSplitGroupings } from './constants' +import type { ReferenceRouteCompilerPlugin } from './code-splitter/plugins' export const splitGroupingsSchema = z .array( @@ -70,6 +71,12 @@ export type CodeSplittingOptions = { * @default true */ addHmr?: boolean + + /** + * Internal compiler plugins used by framework integrations. + * @internal + */ + compilerPlugins?: Array } export type HmrStyle = 'vite' | 'webpack' diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index e291b05fe9c..f197f4890af 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -4,7 +4,7 @@ */ import { fileURLToPath, pathToFileURL } from 'node:url' -import { logDiff } from '@tanstack/router-utils' +import { decodeIdentifier, logDiff } from '@tanstack/router-utils' import { getConfig, splitGroupingsSchema } from './config' import { compileCodeSplitReferenceRoute, @@ -20,7 +20,6 @@ import { tsrShared, tsrSplit, } from './constants' -import { decodeIdentifier } from './code-splitter/path-ids' import { debug, normalizePath } from './utils' import { createRouterPluginContext } from './router-plugin-context' import type { CodeSplitGroupings, SplitRouteIdentNodes } from './constants' @@ -177,11 +176,14 @@ export function createRouterCodeSplitterPlugin( hmrStyle, hmrRouteId: generatorNodeInfo.routeId, sharedBindings: sharedBindings.size > 0 ? sharedBindings : undefined, - compilerPlugins: getReferenceRouteCompilerPlugins({ - targetFramework: userConfig.target, - addHmr, - hmrStyle, - }), + compilerPlugins: [ + ...(getReferenceRouteCompilerPlugins({ + targetFramework: userConfig.target, + addHmr, + hmrStyle, + }) ?? []), + ...(userConfig.codeSplittingOptions?.compilerPlugins ?? []), + ], }) if (compiledReferenceRoute === null) { diff --git a/packages/router-plugin/src/index.ts b/packages/router-plugin/src/index.ts index a56e097d3a8..e390c046a22 100644 --- a/packages/router-plugin/src/index.ts +++ b/packages/router-plugin/src/index.ts @@ -11,6 +11,11 @@ export type { HmrOptions, } from './core/config' export type { RouterPluginContext } from './core/router-plugin-context' +export { getObjectPropertyKeyName } from './core/utils' +export type { + ReferenceRouteCompilerPlugin, + ReferenceRouteCompilerPluginContext, +} from './core/code-splitter/plugins' export { tsrSplit, splitRouteIdentNodes, diff --git a/packages/router-plugin/tests/code-splitter.test.ts b/packages/router-plugin/tests/code-splitter.test.ts index 5a68f9453fe..ae64e2778b3 100644 --- a/packages/router-plugin/tests/code-splitter.test.ts +++ b/packages/router-plugin/tests/code-splitter.test.ts @@ -18,7 +18,7 @@ import { expandTransitively, removeBindingsDependingOnRoute, } from '../src/core/code-splitter/compilers' -import { createIdentifier } from '../src/core/code-splitter/path-ids' +import { createIdentifier } from '@tanstack/router-utils' import { defaultCodeSplitGroupings } from '../src/core/constants' import { frameworks } from './constants' import type { CodeSplitGroupings } from '../src/core/constants' diff --git a/packages/router-utils/src/compiler-helpers.ts b/packages/router-utils/src/compiler-helpers.ts new file mode 100644 index 00000000000..9634c4742f4 --- /dev/null +++ b/packages/router-utils/src/compiler-helpers.ts @@ -0,0 +1,452 @@ +import * as t from '@babel/types' + +/** + * Recursively walk an AST node and collect referenced identifier-like names. + * This avoids Babel path/scope allocation for module-level dependency scans. + */ +export function collectIdentifiersFromNode(node: t.Node): Set { + const ids = new Set() + + ;(function walk( + current: t.Node | null | undefined, + parent?: t.Node, + grandparent?: t.Node, + parentKey?: string, + ) { + if (!current) return + + if (t.isIdentifier(current)) { + if (!parent || t.isReferenced(current, parent, grandparent)) { + ids.add(current.name) + } + return + } + + if (t.isJSXIdentifier(current)) { + if (parent && t.isJSXAttribute(parent) && parentKey === 'name') { + return + } + + if ( + parent && + t.isJSXMemberExpression(parent) && + parentKey === 'property' + ) { + return + } + + const first = current.name[0] + if (first && first === first.toLowerCase()) { + return + } + + ids.add(current.name) + return + } + + for (const key of t.VISITOR_KEYS[current.type] ?? []) { + const child = (current as any)[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item.type === 'string') { + walk(item, current, parent, key) + } + } + } else if (child && typeof child.type === 'string') { + walk(child, current, parent, key) + } + } + })(node) + + return ids +} + +export function collectIdentifiersFromPattern( + node: t.LVal | t.Node | null | undefined, +): Array { + if (!node) { + return [] + } + + if (t.isIdentifier(node)) { + return [node.name] + } + + if (t.isAssignmentPattern(node)) { + return collectIdentifiersFromPattern(node.left) + } + + if (t.isRestElement(node)) { + return collectIdentifiersFromPattern(node.argument) + } + + if (t.isObjectPattern(node)) { + return node.properties.flatMap((prop) => { + if (t.isObjectProperty(prop)) { + return collectIdentifiersFromPattern(prop.value as t.LVal) + } + if (t.isRestElement(prop)) { + return collectIdentifiersFromPattern(prop.argument) + } + return [] + }) + } + + if (t.isArrayPattern(node)) { + return node.elements.flatMap((element) => + collectIdentifiersFromPattern(element), + ) + } + + return [] +} + +export function collectLocalBindingsFromStatement( + node: t.Statement | t.ModuleDeclaration, + bindings: Set, +) { + const declaration = + t.isExportNamedDeclaration(node) && node.declaration + ? node.declaration + : node + + if (t.isVariableDeclaration(declaration)) { + for (const declarator of declaration.declarations) { + for (const name of collectIdentifiersFromPattern(declarator.id)) { + bindings.add(name) + } + } + } else if (t.isFunctionDeclaration(declaration) && declaration.id) { + bindings.add(declaration.id.name) + } else if (t.isClassDeclaration(declaration) && declaration.id) { + bindings.add(declaration.id.name) + } +} + +export function buildDeclarationMap(ast: t.File): Map { + const map = new Map() + + for (const statement of ast.program.body) { + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration + ? statement.declaration + : statement + + if (t.isVariableDeclaration(declaration)) { + for (const declarator of declaration.declarations) { + for (const name of collectIdentifiersFromPattern(declarator.id)) { + map.set(name, declarator) + } + } + } else if (t.isFunctionDeclaration(declaration) && declaration.id) { + map.set(declaration.id.name, declaration) + } else if (t.isClassDeclaration(declaration) && declaration.id) { + map.set(declaration.id.name, declaration) + } + } + + return map +} + +export function buildDependencyGraph( + declarationMap: Map, + localBindings: Set, +): Map> { + const graph = new Map>() + + for (const [name, declarationNode] of declarationMap) { + if (!localBindings.has(name)) continue + + const dependencies = new Set() + for (const id of collectIdentifiersFromNode(declarationNode)) { + if (id !== name && localBindings.has(id)) { + dependencies.add(id) + } + } + graph.set(name, dependencies) + } + + return graph +} + +export function collectModuleLevelRefsFromNode( + node: t.Node, + localModuleLevelBindings: Set, +): Set { + const refs = new Set() + + for (const name of collectIdentifiersFromNode(node)) { + if (localModuleLevelBindings.has(name)) { + refs.add(name) + } + } + + return refs +} + +export function expandTransitively( + bindings: Set, + dependencyGraph: Map>, +) { + const queue = [...bindings] + const visited = new Set() + + while (queue.length > 0) { + const name = queue.pop()! + if (visited.has(name)) continue + visited.add(name) + + const dependencies = dependencyGraph.get(name) + if (!dependencies) continue + + for (const dependency of dependencies) { + if (!bindings.has(dependency)) { + bindings.add(dependency) + queue.push(dependency) + } + } + } +} + +export function expandSharedDestructuredDeclarators( + ast: t.File, + refsByGroup: Map>, + sharedBindings: Set, +) { + for (const statement of ast.program.body) { + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration + ? statement.declaration + : statement + + if (!t.isVariableDeclaration(declaration)) continue + + for (const declarator of declaration.declarations) { + if ( + !t.isObjectPattern(declarator.id) && + !t.isArrayPattern(declarator.id) + ) { + continue + } + + const names = collectIdentifiersFromPattern(declarator.id) + const usedGroups = new Set() + + for (const name of names) { + const groups = refsByGroup.get(name) + if (!groups) continue + for (const group of groups) { + usedGroups.add(group) + } + } + + if (usedGroups.size >= 2) { + for (const name of names) { + sharedBindings.add(name) + } + } + } + } +} + +export function expandDestructuredDeclarations( + ast: t.File, + bindings: Set, +) { + for (const statement of ast.program.body) { + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration + ? statement.declaration + : statement + + if (!t.isVariableDeclaration(declaration)) continue + + for (const declarator of declaration.declarations) { + if ( + !t.isObjectPattern(declarator.id) && + !t.isArrayPattern(declarator.id) + ) { + continue + } + + const names = collectIdentifiersFromPattern(declarator.id) + if (names.some((name) => bindings.has(name))) { + for (const name of names) { + bindings.add(name) + } + } + } + } +} + +export function removeBindingsTransitivelyDependingOn( + bindings: Set, + dependencyGraph: Map>, + roots: Iterable, +) { + const reverseGraph = new Map>() + + for (const [name, dependencies] of dependencyGraph) { + for (const dependency of dependencies) { + let parents = reverseGraph.get(dependency) + if (!parents) { + parents = new Set() + reverseGraph.set(dependency, parents) + } + parents.add(name) + } + } + + const visited = new Set() + const queue = [...roots] + + while (queue.length > 0) { + const current = queue.pop()! + if (visited.has(current)) continue + visited.add(current) + + const parents = reverseGraph.get(current) + if (!parents) continue + + for (const parent of parents) { + if (!visited.has(parent)) { + queue.push(parent) + } + } + } + + for (const name of [...bindings]) { + if (visited.has(name)) { + bindings.delete(name) + } + } +} + +export function removeModuleLevelBindings( + ast: t.File, + namesToRemove: Set, +) { + ast.program.body = ast.program.body.filter((statement) => { + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration + ? statement.declaration + : statement + + if (t.isVariableDeclaration(declaration)) { + declaration.declarations = declaration.declarations.filter( + (declarator) => + !collectIdentifiersFromPattern(declarator.id).some((name) => + namesToRemove.has(name), + ), + ) + return declaration.declarations.length > 0 + } + + if (t.isFunctionDeclaration(declaration) && declaration.id) { + return !namesToRemove.has(declaration.id.name) + } + + if (t.isClassDeclaration(declaration) && declaration.id) { + return !namesToRemove.has(declaration.id.name) + } + + if (t.isExportDefaultDeclaration(statement)) { + const defaultDeclaration = statement.declaration + if ( + (t.isFunctionDeclaration(defaultDeclaration) || + t.isClassDeclaration(defaultDeclaration)) && + defaultDeclaration.id + ) { + return !namesToRemove.has(defaultDeclaration.id.name) + } + } + + return true + }) +} + +export function retainModuleLevelDeclarations( + ast: t.File, + bindingsToKeep: Set, +) { + ast.program.body = ast.program.body.filter((statement) => { + if (t.isImportDeclaration(statement)) return true + + const declaration = + t.isExportNamedDeclaration(statement) && statement.declaration + ? statement.declaration + : statement + + if (t.isVariableDeclaration(declaration)) { + declaration.declarations = declaration.declarations.filter((declarator) => + collectIdentifiersFromPattern(declarator.id).some((name) => + bindingsToKeep.has(name), + ), + ) + return declaration.declarations.length > 0 + } + + if (t.isFunctionDeclaration(declaration) && declaration.id) { + return bindingsToKeep.has(declaration.id.name) + } + + if (t.isClassDeclaration(declaration) && declaration.id) { + return bindingsToKeep.has(declaration.id.name) + } + + return false + }) +} + +export function unwrapExportedDeclarations(ast: t.File) { + const body: Array = [] + + for (const statement of ast.program.body) { + if (t.isExportNamedDeclaration(statement)) { + if (statement.declaration) { + body.push(statement.declaration) + } + continue + } + + if (t.isExportDefaultDeclaration(statement)) { + const declaration = statement.declaration + if ( + (t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration)) && + declaration.id + ) { + body.push(declaration) + } + continue + } + + if (t.isExportAllDeclaration(statement)) { + continue + } + + body.push(statement) + } + + ast.program.body = body +} + +export function stripUnreferencedTopLevelExpressionStatements(ast: t.File) { + const locallyBound = new Set() + + for (const statement of ast.program.body) { + collectLocalBindingsFromStatement(statement, locallyBound) + } + + ast.program.body = ast.program.body.filter((statement) => { + if (!t.isExpressionStatement(statement)) return true + + for (const name of collectIdentifiersFromNode(statement)) { + if (locallyBound.has(name)) { + return true + } + } + + return false + }) +} diff --git a/packages/router-utils/src/index.ts b/packages/router-utils/src/index.ts index 3b072ae4199..ccaa23a5444 100644 --- a/packages/router-utils/src/index.ts +++ b/packages/router-utils/src/index.ts @@ -9,3 +9,22 @@ export type { ParseAstOptions, ParseAstResult, GeneratorResult } from './ast' export { logDiff } from './logger' export { copyFilesPlugin } from './copy-files-plugin' + +export { createIdentifier, decodeIdentifier } from './path-ids' + +export { + buildDeclarationMap, + buildDependencyGraph, + collectIdentifiersFromNode, + collectIdentifiersFromPattern, + collectLocalBindingsFromStatement, + collectModuleLevelRefsFromNode, + expandDestructuredDeclarations, + expandSharedDestructuredDeclarators, + expandTransitively, + removeBindingsTransitivelyDependingOn, + removeModuleLevelBindings, + retainModuleLevelDeclarations, + stripUnreferencedTopLevelExpressionStatements, + unwrapExportedDeclarations, +} from './compiler-helpers' diff --git a/packages/router-plugin/src/core/code-splitter/path-ids.ts b/packages/router-utils/src/path-ids.ts similarity index 100% rename from packages/router-plugin/src/core/code-splitter/path-ids.ts rename to packages/router-utils/src/path-ids.ts diff --git a/packages/solid-router/src/ClientOnly.tsx b/packages/solid-router/src/ClientOnly.tsx index 6ed10d267b3..a7c4b718197 100644 --- a/packages/solid-router/src/ClientOnly.tsx +++ b/packages/solid-router/src/ClientOnly.tsx @@ -56,10 +56,15 @@ export function ClientOnly(props: ClientOnlyProps) { * ``` * @returns True if the JS has been hydrated already, false otherwise. */ +let globalHydrated = false + export function useHydrated(): Solid.Accessor { - const [hydrated, setHydrated] = Solid.createSignal(false) + const [hydrated, setHydrated] = Solid.createSignal(globalHydrated) + Solid.onMount(() => { + globalHydrated = true setHydrated(true) }) + return hydrated } diff --git a/packages/solid-start-client/package.json b/packages/solid-start-client/package.json index 2fbe68cba4c..7c730f3f9b4 100644 --- a/packages/solid-start-client/package.json +++ b/packages/solid-start-client/package.json @@ -48,6 +48,12 @@ "default": "./dist/esm/index.js" } }, + "./hydration": { + "import": { + "types": "./dist/esm/hydration.d.ts", + "default": "./dist/esm/hydration.js" + } + }, "./package.json": "./package.json" }, "sideEffects": false, diff --git a/packages/solid-start-client/src/GenericHydrate.tsx b/packages/solid-start-client/src/GenericHydrate.tsx new file mode 100644 index 00000000000..7bbb078d300 --- /dev/null +++ b/packages/solid-start-client/src/GenericHydrate.tsx @@ -0,0 +1,266 @@ +import * as Solid from 'solid-js' +import { Dynamic, NoHydration, createComponent } from 'solid-js/web' + +import { useHydrated } from '@tanstack/solid-router' +import { isServer } from '@tanstack/router-core/isServer' +import { + hydrateIdAttribute, + hydrateWhenAttribute, +} from '@tanstack/start-client-core/hydration/constants' +import { + createResolvedGate, + getOrCreateGate, + onGateResolve, + releaseGate, +} from '@tanstack/start-client-core/hydration/runtime' +import type { HydrationRuntimeContext } from '@tanstack/start-client-core/hydration' +import type { HydrationGateRecord } from '@tanstack/start-client-core/hydration/runtime' +import type { HydrateProps, InternalHydrateProps } from './Hydrate' + +const hydrateIdSelector = `[${hydrateIdAttribute}]` + +function shouldDeferHydration(strategy: InternalHydrateProps['when']) { + return strategy.shouldDefer + ? strategy.shouldDefer() + : strategy.type !== 'load' +} + +function runStrategyCleanup(cleanup: void | (() => void)) { + if (typeof cleanup === 'function') return cleanup + return undefined +} + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: Solid.JSX.Element +}) { + let didHydrate = false + + Solid.onMount(() => { + if (didHydrate) return + didHydrate = true + props.onHydrated?.() + props.onStrategyHydrated?.(props.id) + }) + + return props.children +} + +export function GenericHydrate(props: HydrateProps) { + const internalProps = props as InternalHydrateProps + const hydrateStrategy = () => internalProps.when + const prefetchStrategy = () => internalProps.prefetch + const delegatedStrategy = () => internalProps.__hydrate + const hydrated = useHydrated() + const uniqueId = Solid.createUniqueId() + const id = internalProps.splitId + ? `${internalProps.splitId}${uniqueId}` + : uniqueId + const initialHydrateStrategy = hydrateStrategy() + const shouldPreserveServerHTML = + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + !hydrated() + const shouldDeferInitialHydration = + !hydrated() && shouldDeferHydration(initialHydrateStrategy) + const gate: HydrationGateRecord = + ((isServer as boolean | undefined) ?? typeof window === 'undefined') + ? createResolvedGate(id, initialHydrateStrategy.type) + : getOrCreateGate(id, initialHydrateStrategy.type) + const [ready, setReady] = Solid.createSignal( + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + (!shouldDeferInitialHydration && initialHydrateStrategy.type !== 'never'), + ) + let didPrefetch = false + let markerElement: HTMLDivElement | undefined + + if ( + !((isServer as boolean | undefined) ?? typeof window === 'undefined') && + initialHydrateStrategy.type !== 'never' && + (!shouldDeferInitialHydration || + !shouldDeferHydration(initialHydrateStrategy)) + ) { + gate.resolve() + } + + Solid.onMount(() => { + const currentHydrateStrategy = hydrateStrategy() + const currentPrefetchStrategy = prefetchStrategy() + const currentDelegatedStrategy = delegatedStrategy() + gate.when = currentHydrateStrategy.type + for (const element of document.querySelectorAll( + hydrateIdSelector, + )) { + if (element.getAttribute(hydrateIdAttribute) === id) { + markerElement = element + break + } + } + + if ( + currentHydrateStrategy.type === 'never' && + !shouldPreserveServerHTML && + markerElement + ) { + markerElement.replaceChildren() + } + + if (internalProps.preload && currentPrefetchStrategy) { + const prefetch = () => { + if (didPrefetch) return + didPrefetch = true + void internalProps.preload?.() + } + const cleanupPrefetch = runStrategyCleanup( + currentPrefetchStrategy.setupPrefetch?.({ + element: markerElement ?? null, + prefetch, + }), + ) + if (cleanupPrefetch) Solid.onCleanup(cleanupPrefetch) + } + + if ( + currentHydrateStrategy.type !== 'never' && + (!shouldDeferInitialHydration || + !shouldDeferHydration(currentHydrateStrategy)) + ) { + gate.resolve() + setReady(true) + } + + const cleanups: Array<() => void> = [] + let removeResolveListener = () => {} + let disposed = false + + const resolveBoundary = () => { + if (shouldPreserveServerHTML && markerElement && !ready()) { + markerElement.replaceChildren() + } + setReady(true) + } + + const cleanup = () => { + if (disposed) return + disposed = true + removeResolveListener() + cleanups.splice(0).forEach((fn) => fn()) + } + + const addCleanup = (fn: void | (() => void)) => { + if (!fn) return + if (disposed || gate.resolved) { + fn() + return + } + cleanups.push(fn) + } + + Solid.onCleanup(() => { + cleanup() + releaseGate(gate) + }) + + removeResolveListener = onGateResolve(gate, () => { + cleanup() + resolveBoundary() + }) + + if ( + gate.resolved || + !shouldDeferInitialHydration || + currentHydrateStrategy.type === 'never' + ) { + if (gate.resolved) resolveBoundary() + return + } + + const context: HydrationRuntimeContext = { + element: markerElement ?? null, + gate, + } + addCleanup(runStrategyCleanup(currentHydrateStrategy.setup?.(context))) + + if (currentDelegatedStrategy?.setup) { + addCleanup( + runStrategyCleanup( + currentDelegatedStrategy.setup({ + ...context, + delegated: true, + }), + ), + ) + } + }) + + Solid.createEffect(() => { + const currentHydrateStrategy = hydrateStrategy() + if ( + ((isServer as boolean | undefined) ?? typeof window === 'undefined') || + gate.resolved || + currentHydrateStrategy.type === 'never' || + shouldDeferHydration(currentHydrateStrategy) + ) { + return + } + + gate.resolve() + }) + + const markerAttributes = hydrateStrategy().getMarkerAttributes?.() + + return createComponent(Dynamic as any, { + component: 'div', + get [hydrateIdAttribute]() { + return id + }, + get [hydrateWhenAttribute]() { + return hydrateStrategy().type + }, + ...markerAttributes, + get children() { + if (hydrateStrategy().type === 'never' && !shouldPreserveServerHTML) { + return props.fallback ?? null + } + + return createComponent(Solid.Suspense, { + get fallback() { + return shouldPreserveServerHTML ? null : (props.fallback ?? null) + }, + get children() { + return createComponent(Solid.Show as any, { + get when() { + return ready() + }, + get fallback() { + return shouldPreserveServerHTML + ? createComponent(NoHydration, { + get children() { + return props.children + }, + }) + : (props.fallback ?? null) + }, + get children() { + return createComponent(HydratedBoundary, { + get id() { + return id + }, + get onHydrated() { + return props.onHydrated + }, + get onStrategyHydrated() { + return hydrateStrategy().onHydrated + }, + get children() { + return props.children + }, + }) + }, + }) + }, + }) + }, + }) +} diff --git a/packages/solid-start-client/src/Hydrate.tsx b/packages/solid-start-client/src/Hydrate.tsx new file mode 100644 index 00000000000..046955d4ad2 --- /dev/null +++ b/packages/solid-start-client/src/Hydrate.tsx @@ -0,0 +1,51 @@ +import type * as Solid from 'solid-js' + +import type { + HydrationStrategy as CoreHydrationStrategy, + HydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration' + +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' + +export type SolidHydrationStrategy = CoreHydrationStrategy & { + $$renderHydrate: (props: HydrateProps) => Solid.JSX.Element +} + +export type HydrationStrategy = SolidHydrationStrategy + +export type HydrateOptions = { + when: SolidHydrationStrategy +} + +type HydrateCommonProps = { + fallback?: Solid.JSX.Element + onHydrated?: () => void + children: Solid.JSX.Element +} + +export type HydrateProps = + | (HydrateCommonProps & + HydrateOptions & { + prefetch?: never + split?: boolean + }) + | (HydrateCommonProps & + HydrateOptions & { + prefetch: HydrationPrefetchStrategy + split?: true + }) + +export type InternalHydrateProps = HydrateProps & { + __hydrate?: CoreHydrationStrategy + splitId?: string + preload?: () => Promise +} + +export function Hydrate(props: HydrateProps) { + return props.when.$$renderHydrate(props) +} diff --git a/packages/solid-start-client/src/hydration.ts b/packages/solid-start-client/src/hydration.ts new file mode 100644 index 00000000000..b6654b3e4d7 --- /dev/null +++ b/packages/solid-start-client/src/hydration.ts @@ -0,0 +1,14 @@ +export { condition, interaction, media } from './hydration/generic' +export { idle } from './hydration/idle' +export { load } from './hydration/load' +export { never } from './hydration/never' +export { visible } from './hydration/visible' +export type { + HydrationCondition, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationWhen, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +export type { HydrationStrategy, SolidHydrationStrategy } from './Hydrate' diff --git a/packages/solid-start-client/src/hydration/generic.ts b/packages/solid-start-client/src/hydration/generic.ts new file mode 100644 index 00000000000..e9b116b6b16 --- /dev/null +++ b/packages/solid-start-client/src/hydration/generic.ts @@ -0,0 +1,36 @@ +import { + condition as coreCondition, + interaction as coreInteraction, + media as coreMedia, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationCondition, + HydrationInteractionEvents, + HydrationStrategy, +} from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +function withGenericRenderer( + strategy: T, +): T & SolidHydrationStrategy { + return /* @__PURE__ */ Object.assign(strategy, { + $$renderHydrate: GenericHydrate, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function media(query: string) { + return /* @__PURE__ */ withGenericRenderer(coreMedia(query)) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function condition(condition: HydrationCondition) { + return /* @__PURE__ */ withGenericRenderer(coreCondition(condition)) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction(options?: { events?: HydrationInteractionEvents }) { + return /* @__PURE__ */ withGenericRenderer(coreInteraction(options)) +} diff --git a/packages/solid-start-client/src/hydration/idle.ts b/packages/solid-start-client/src/hydration/idle.ts new file mode 100644 index 00000000000..210177b6f73 --- /dev/null +++ b/packages/solid-start-client/src/hydration/idle.ts @@ -0,0 +1,13 @@ +import { idle as coreIdle } from '@tanstack/start-client-core/hydration' +import { StrategyHydrate } from './visible' +import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function idle(options?: { + timeout?: number +}): SolidHydrationStrategy & HydrationPrefetchStrategy { + return /* @__PURE__ */ Object.assign(coreIdle(options), { + $$renderHydrate: StrategyHydrate, + }) +} diff --git a/packages/solid-start-client/src/hydration/load.tsx b/packages/solid-start-client/src/hydration/load.tsx new file mode 100644 index 00000000000..080f3d37d35 --- /dev/null +++ b/packages/solid-start-client/src/hydration/load.tsx @@ -0,0 +1,79 @@ +import * as Solid from 'solid-js' +import { Dynamic, createComponent } from 'solid-js/web' + +import { load as coreLoad } from '@tanstack/start-client-core/hydration' +import { + hydrateIdAttribute, + hydrateWhenAttribute, +} from '@tanstack/start-client-core/hydration/constants' +import type { + HydrateProps, + InternalHydrateProps, + SolidHydrationStrategy, +} from '../Hydrate' + +const loadType = 'load' + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: Solid.JSX.Element +}) { + let didHydrate = false + + Solid.onMount(() => { + if (didHydrate) return + didHydrate = true + props.onHydrated?.() + props.onStrategyHydrated?.(props.id) + }) + + return props.children +} + +export function LoadHydrate(props: HydrateProps) { + const internalProps = props as InternalHydrateProps + const uniqueId = Solid.createUniqueId() + const id = internalProps.splitId + ? `${internalProps.splitId}${uniqueId}` + : uniqueId + + return createComponent(Dynamic as any, { + component: 'div', + get [hydrateIdAttribute]() { + return id + }, + [hydrateWhenAttribute]: loadType, + get children() { + return createComponent(Solid.Suspense, { + get fallback() { + return props.fallback ?? null + }, + get children() { + return createComponent(HydratedBoundary, { + get id() { + return id + }, + get onHydrated() { + return props.onHydrated + }, + get onStrategyHydrated() { + return internalProps.when.onHydrated + }, + get children() { + return props.children + }, + }) + }, + }) + }, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): SolidHydrationStrategy { + return /* @__PURE__ */ Object.assign(coreLoad(), { + $$renderHydrate: LoadHydrate, + }) +} diff --git a/packages/solid-start-client/src/hydration/never.ts b/packages/solid-start-client/src/hydration/never.ts new file mode 100644 index 00000000000..8477b8b2630 --- /dev/null +++ b/packages/solid-start-client/src/hydration/never.ts @@ -0,0 +1,10 @@ +import { never as coreNever } from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function never(): SolidHydrationStrategy { + return /* @__PURE__ */ Object.assign(coreNever(), { + $$renderHydrate: GenericHydrate, + }) +} diff --git a/packages/solid-start-client/src/hydration/visible.tsx b/packages/solid-start-client/src/hydration/visible.tsx new file mode 100644 index 00000000000..13c1f10766b --- /dev/null +++ b/packages/solid-start-client/src/hydration/visible.tsx @@ -0,0 +1,180 @@ +import * as Solid from 'solid-js' +import { Dynamic, createComponent } from 'solid-js/web' + +import { isServer } from '@tanstack/router-core/isServer' +import { hydrateIdAttribute } from '@tanstack/start-client-core/hydration/constants' +import type { + HydrationPrefetchStrategy, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { + HydrateProps, + InternalHydrateProps, + SolidHydrationStrategy, +} from '../Hydrate' + +const visibleType = 'visible' + +type VisibleGate = { + resolved: boolean + resolve: () => void +} + +function observeVisible( + element: Element | null, + callback: () => void, + rootMargin: string, + threshold: number | Array, +) { + if (!element || typeof IntersectionObserver !== 'function') { + callback() + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries.some((entry) => entry.isIntersecting)) return + observer.disconnect() + callback() + }, + { rootMargin, threshold }, + ) + observer.observe(element) + return () => observer.disconnect() +} + +function createGate() { + const gate: VisibleGate = { + resolved: false, + resolve: () => { + if (gate.resolved) return + gate.resolved = true + }, + } + return gate +} + +function HydratedBoundary(props: { + id: string + onHydrated?: () => void + onStrategyHydrated?: (id: string) => void + children: Solid.JSX.Element +}) { + let didHydrate = false + + Solid.onMount(() => { + if (didHydrate) return + didHydrate = true + props.onHydrated?.() + props.onStrategyHydrated?.(props.id) + }) + + return props.children +} + +export function StrategyHydrate(props: HydrateProps) { + const internalProps = props as InternalHydrateProps + const strategy = internalProps.when + const prefetchStrategy = internalProps.prefetch + const uniqueId = Solid.createUniqueId() + const id = internalProps.splitId + ? `${internalProps.splitId}${uniqueId}` + : uniqueId + const gate = createGate() + const [ready, setReady] = Solid.createSignal( + (isServer as boolean | undefined) ?? typeof window === 'undefined', + ) + let didPrefetch = false + let markerElement: HTMLDivElement | undefined + + if ((isServer as boolean | undefined) ?? typeof window === 'undefined') { + gate.resolve() + } + + Solid.onMount(() => { + if (internalProps.preload && prefetchStrategy) { + const prefetch = () => { + if (didPrefetch) return + didPrefetch = true + void internalProps.preload?.() + } + const cleanupPrefetch = prefetchStrategy.setupPrefetch?.({ + element: markerElement ?? null, + prefetch, + }) + if (typeof cleanupPrefetch === 'function') + Solid.onCleanup(cleanupPrefetch) + } + + if (gate.resolved) { + setReady(true) + return + } + + const cleanup = strategy.setup?.({ + element: markerElement ?? null, + gate: gate as any, + }) + if (typeof cleanup === 'function') Solid.onCleanup(cleanup) + }) + + const resolve = gate.resolve + gate.resolve = () => { + resolve() + setReady(true) + } + + return createComponent(Dynamic as any, { + component: 'div', + ref(element: HTMLDivElement) { + markerElement = element + }, + get [hydrateIdAttribute]() { + return id + }, + get children() { + return createComponent(Solid.Show as any, { + get when() { + return ready() + }, + get fallback() { + return props.fallback ?? null + }, + get children() { + return createComponent(HydratedBoundary, { + get id() { + return id + }, + get onHydrated() { + return props.onHydrated + }, + get onStrategyHydrated() { + return strategy.onHydrated + }, + get children() { + return props.children + }, + }) + }, + }) + }, + }) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options?: VisibleHydrationOptions, +): SolidHydrationStrategy & HydrationPrefetchStrategy { + const rootMargin = options?.rootMargin ?? '600px' + const threshold = options?.threshold ?? 0 + + return { + type: visibleType, + key: visibleType, + setup: ({ element, gate }: any) => + observeVisible(element, gate.resolve, rootMargin, threshold), + setupPrefetch: ({ element, prefetch }: any) => + observeVisible(element, prefetch, rootMargin, threshold), + $$renderHydrate: StrategyHydrate, + } +} diff --git a/packages/solid-start-client/src/index.tsx b/packages/solid-start-client/src/index.tsx index aa73990a576..97728988bf4 100644 --- a/packages/solid-start-client/src/index.tsx +++ b/packages/solid-start-client/src/index.tsx @@ -1,2 +1,13 @@ export { StartClient } from './StartClient' export { hydrateStart } from './hydrateStart' +export { Hydrate } from './Hydrate' +export { lazyHydratedComponent } from './lazyHydratedComponent' +export type { + HydrateOptions, + HydrateProps, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, +} from './Hydrate' diff --git a/packages/solid-start-client/src/lazyHydratedComponent.tsx b/packages/solid-start-client/src/lazyHydratedComponent.tsx new file mode 100644 index 00000000000..9e60a36ef8d --- /dev/null +++ b/packages/solid-start-client/src/lazyHydratedComponent.tsx @@ -0,0 +1,9 @@ +import { lazyRouteComponent } from '@tanstack/solid-router' +import type { AsyncRouteComponent } from '@tanstack/solid-router' + +export function lazyHydratedComponent>( + importer: () => Promise, + exportName: string, +): AsyncRouteComponent { + return lazyRouteComponent(importer, exportName) +} diff --git a/packages/solid-start-client/src/tests/Hydrate.test-d.tsx b/packages/solid-start-client/src/tests/Hydrate.test-d.tsx new file mode 100644 index 00000000000..218f5da1735 --- /dev/null +++ b/packages/solid-start-client/src/tests/Hydrate.test-d.tsx @@ -0,0 +1,85 @@ +import { expectTypeOf, test } from 'vitest' +import { Hydrate } from '../Hydrate' +import type { + HydrateProps, + HydrationPrefetchStrategy, + HydrationStrategy, +} from '../Hydrate' +import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration' +import type { visible } from '../hydration' +import type { JSX } from 'solid-js' + +type CommonHydrateProps = { + fallback?: JSX.Element + onHydrated?: () => void + children: JSX.Element +} + +type SplitHydrateProps = CommonHydrateProps & { + when: HydrationStrategy + prefetch?: never + split?: boolean +} + +type PrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split?: true +} + +test('Hydrate component accepts the public HydrateProps type', () => { + expectTypeOf(Hydrate).toBeFunction() + expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf() +}) + +test('Hydrate props are exact for strategy and prefetch forms', () => { + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() +}) + +test('Hydrate requires a strategy', () => { + expectTypeOf<{ + when: HydrationStrategy + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + children: JSX.Element + }>().not.toMatchTypeOf() +}) + +test('Hydrate requires a framework-renderable strategy', () => { + expectTypeOf().not.toMatchTypeOf() + expectTypeOf>().toMatchTypeOf() + + expectTypeOf<{ + when: CoreHydrationStrategy + children: JSX.Element + }>().not.toMatchTypeOf() +}) + +test('Hydrate enforces prefetch only with split boundaries', () => { + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split: true + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchStrategy + split: false + children: JSX.Element + }>().not.toMatchTypeOf() +}) diff --git a/packages/solid-start-client/vite.config.ts b/packages/solid-start-client/vite.config.ts index be71bab5183..4b0a771bdd3 100644 --- a/packages/solid-start-client/vite.config.ts +++ b/packages/solid-start-client/vite.config.ts @@ -20,7 +20,12 @@ export default mergeConfig( tanstackViteConfig({ tsconfigPath: './tsconfig.build.json', srcDir: './src', - entry: './src/index.tsx', + entry: [ + './src/index.tsx', + './src/Hydrate.tsx', + './src/hydration.ts', + './src/lazyHydratedComponent.tsx', + ], cjs: false, }), ) diff --git a/packages/solid-start/package.json b/packages/solid-start/package.json index fd830062e06..9a5b4e8a79e 100644 --- a/packages/solid-start/package.json +++ b/packages/solid-start/package.json @@ -44,6 +44,12 @@ "default": "./dist/esm/client.js" } }, + "./hydration": { + "import": { + "types": "./dist/esm/hydration.d.ts", + "default": "./dist/esm/hydration.js" + } + }, "./client-rpc": { "import": { "types": "./dist/esm/client-rpc.d.ts", diff --git a/packages/solid-start/src/hydration.ts b/packages/solid-start/src/hydration.ts new file mode 100644 index 00000000000..fc660e9dc51 --- /dev/null +++ b/packages/solid-start/src/hydration.ts @@ -0,0 +1,18 @@ +export { + condition, + idle, + interaction, + load, + media, + never, + visible, +} from '@tanstack/solid-start-client/hydration' +export type { + HydrationCondition, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, + VisibleHydrationOptions, +} from '@tanstack/solid-start-client/hydration' diff --git a/packages/solid-start/src/index.ts b/packages/solid-start/src/index.ts index 8b51b6c7832..23a1f48d904 100644 --- a/packages/solid-start/src/index.ts +++ b/packages/solid-start/src/index.ts @@ -1,2 +1,12 @@ export { useServerFn } from './useServerFn' export * from '@tanstack/start-client-core' +export { Hydrate, lazyHydratedComponent } from '@tanstack/solid-start-client' +export type { + HydrateOptions, + HydrateProps, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationStrategy, + HydrationWhen, +} from '@tanstack/solid-start-client' diff --git a/packages/solid-start/vite.config.ts b/packages/solid-start/vite.config.ts index 262208de090..2c1a42d0e49 100644 --- a/packages/solid-start/vite.config.ts +++ b/packages/solid-start/vite.config.ts @@ -27,6 +27,7 @@ export default mergeConfig( entry: [ './src/index.ts', './src/client.tsx', + './src/hydration.ts', './src/client-rpc.ts', './src/ssr-rpc.ts', './src/server-rpc.ts', diff --git a/packages/start-client-core/package.json b/packages/start-client-core/package.json index 0d3d845dd96..1f5629f790b 100644 --- a/packages/start-client-core/package.json +++ b/packages/start-client-core/package.json @@ -60,16 +60,37 @@ "default": "./dist/esm/client-rpc/index.js" } }, + "./hydration": { + "import": { + "types": "./dist/esm/hydration.d.ts", + "default": "./dist/esm/hydration.js" + } + }, + "./hydration/constants": { + "import": { + "types": "./dist/esm/hydration/constants.d.ts", + "default": "./dist/esm/hydration/constants.js" + } + }, + "./hydration/runtime": { + "import": { + "types": "./dist/esm/hydration/runtime.d.ts", + "default": "./dist/esm/hydration/runtime.js" + } + }, "./package.json": "./package.json" }, "imports": { "#tanstack-start-entry": { + "types": "./src/start-entry.d.ts", "default": "./dist/esm/fake-entries/start.js" }, "#tanstack-router-entry": { + "types": "./src/start-entry.d.ts", "default": "./dist/esm/fake-entries/router.js" }, "#tanstack-start-plugin-adapters": { + "types": "./src/start-entry.d.ts", "default": "./dist/esm/fake-entries/plugin-adapters.js" } }, diff --git a/packages/start-client-core/src/hydration.ts b/packages/start-client-core/src/hydration.ts new file mode 100644 index 00000000000..008d942014c --- /dev/null +++ b/packages/start-client-core/src/hydration.ts @@ -0,0 +1,40 @@ +import { hydrateIdAttribute } from './hydration/constants' + +export { condition } from './hydration/condition' +export type { HydrationCondition } from './hydration/condition' +export { + hydrateIdAttribute, + hydrateInteractionEventsAttribute, + hydrateWhenAttribute, +} from './hydration/constants' +export const hydrateIdSelector = `[${hydrateIdAttribute}]` +export { idle } from './hydration/idle' +export { interaction } from './hydration/interaction' +export { load } from './hydration/load' +export { media } from './hydration/media' +export { never } from './hydration/never' +export { + clearResolvedGateIdsInMarker, + createResolvedGate, + getFallbackHtml, + getMarkerGate, + getOrCreateGate, + onGateResolve, + releaseGate, + resolveHydrationMarker, + saveFallbackHtml, +} from './hydration/runtime' +export { visible } from './hydration/visible' +export type { VisibleHydrationOptions } from './hydration/visible' +export type { HydrationGateRecord } from './hydration/runtime' +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationMarkerAttributes, + HydrationPrefetchStrategy, + HydrationPrefetchContext, + HydrationRuntimeContext, + HydrationRuntimeGate, + HydrationStrategy, + HydrationWhen, +} from './hydration/types' diff --git a/packages/start-client-core/src/hydration/condition.ts b/packages/start-client-core/src/hydration/condition.ts new file mode 100644 index 00000000000..b961f2d675a --- /dev/null +++ b/packages/start-client-core/src/hydration/condition.ts @@ -0,0 +1,23 @@ +import type { HydrationStrategy } from './types' + +const conditionType = 'condition' + +export type HydrationCondition = boolean | (() => boolean) + +function readCondition(condition: HydrationCondition) { + return typeof condition === 'function' ? condition() : condition +} + +/* @__NO_SIDE_EFFECTS__ */ +export function condition(condition: HydrationCondition): HydrationStrategy { + const conditionValue = readCondition(condition) + + return { + type: conditionType, + key: `${conditionType}:${conditionValue ? '1' : '0'}`, + shouldDefer: () => !readCondition(condition), + setup: ({ gate }) => { + if (readCondition(condition)) gate.resolve() + }, + } +} diff --git a/packages/start-client-core/src/hydration/constants.ts b/packages/start-client-core/src/hydration/constants.ts new file mode 100644 index 00000000000..4b7a29313d6 --- /dev/null +++ b/packages/start-client-core/src/hydration/constants.ts @@ -0,0 +1,4 @@ +export const hydrateIdAttribute = 'data-ts-hydrate-id' +export const hydrateWhenAttribute = 'data-ts-hydrate-when' +export const hydrateInteractionEventsAttribute = + 'data-ts-hydrate-interaction-events' diff --git a/packages/start-client-core/src/hydration/idle.ts b/packages/start-client-core/src/hydration/idle.ts new file mode 100644 index 00000000000..af3e95d3adc --- /dev/null +++ b/packages/start-client-core/src/hydration/idle.ts @@ -0,0 +1,42 @@ +import type { HydrationPrefetchStrategy, HydrationStrategy } from './types' + +const idleType = 'idle' + +export type IdleHydrationOptions = { + timeout?: number +} + +function getIdleScheduler() { + return globalThis as unknown as { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void + } +} + +function scheduleIdle(callback: () => void, timeout: number) { + const schedule = getIdleScheduler() + if (schedule.requestIdleCallback) { + const handle = schedule.requestIdleCallback(callback, { timeout }) + return () => schedule.cancelIdleCallback?.(handle) + } + + const timeoutId = globalThis.setTimeout(callback, timeout) + return () => globalThis.clearTimeout(timeoutId) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function idle( + options: IdleHydrationOptions = {}, +): HydrationStrategy & HydrationPrefetchStrategy { + const timeout = options.timeout ?? 2000 + + return { + type: idleType, + key: `${idleType}:${timeout}`, + setup: ({ gate }) => scheduleIdle(gate.resolve, timeout), + setupPrefetch: ({ prefetch }) => scheduleIdle(prefetch, timeout), + } +} diff --git a/packages/start-client-core/src/hydration/interaction.ts b/packages/start-client-core/src/hydration/interaction.ts new file mode 100644 index 00000000000..8f5b01d7f62 --- /dev/null +++ b/packages/start-client-core/src/hydration/interaction.ts @@ -0,0 +1,295 @@ +import { + hydrateIdAttribute, + hydrateInteractionEventsAttribute, + hydrateWhenAttribute, +} from './constants' +import { + clearResolvedGateIdsInMarker, + getMarkerGate, + resolveHydrationMarker, +} from './runtime' +import type { + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationRuntimeContext, + HydrationStrategy, +} from './types' + +export type InteractionHydrationOptions = { + events?: HydrationInteractionEvents +} + +const hydrateIdSelector = `[${hydrateIdAttribute}]` + +type PendingReplayEvent = { + marker: Element + targetPath: Array + type: string + event: Event +} + +const defaultInteractionEvents = [ + 'pointerenter', + 'focusin', + 'pointerdown', + 'click', +] as const +const interactionType = 'interaction' +const interactionHydrateSelector = `[${hydrateWhenAttribute}="${interactionType}"]` +const replayEventsByGateId = new Map>() + +function normalizeInteractionEvents( + events?: HydrationInteractionEvents, +): ReadonlyArray { + if (events === undefined) return defaultInteractionEvents + + const eventList: ReadonlyArray = + typeof events === 'string' ? [events] : events + const normalizedEvents: Array = [] + const seen = new Set() + + for (const eventName of eventList) { + if (!eventName || seen.has(eventName)) continue + seen.add(eventName) + normalizedEvents.push(eventName) + } + + return normalizedEvents +} + +function getIntentListenerEvents( + marker: Element, + events: ReadonlyArray, +) { + const listenerEvents = new Set(events) + + marker.querySelectorAll(interactionHydrateSelector).forEach((childMarker) => { + const attr = childMarker.getAttribute(hydrateInteractionEventsAttribute) + for (const eventName of attr === null + ? defaultInteractionEvents + : attr.split(/\s+/).filter(Boolean)) { + listenerEvents.add(eventName) + } + }) + + return [...listenerEvents] +} + +function getReplayTargetPath(marker: Element, target: EventTarget) { + if (!(target instanceof Node) || !marker.contains(target)) return [] + + const path: Array = [] + let node: Element | null = + target instanceof Element ? target : target.parentElement + + while (node && node !== marker) { + const parent = node.parentElement + if (!parent) return [] + path.push(Array.prototype.indexOf.call(parent.children, node)) + node = parent + } + + return path.reverse() +} + +function queueHydrationReplayEvent(marker: Element, event: Event) { + if (!event.bubbles) return + + const id = marker.getAttribute(hydrateIdAttribute) + if (!id || marker.getAttribute(hydrateWhenAttribute) === 'never') return + + const target = event.target + if (!target) return + + const gate = getMarkerGate(marker) + if (!gate || gate.resolved) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + const pendingEvents = replayEventsByGateId.get(id) ?? [] + pendingEvents.push({ + marker, + targetPath: getReplayTargetPath(marker, target), + type: event.type, + event, + }) + replayEventsByGateId.set(id, pendingEvents) +} + +function createReplayEvent(event: Event) { + const init = { + bubbles: event.bubbles, + cancelable: event.cancelable, + composed: event.composed, + } + + if (event instanceof MouseEvent) { + return new MouseEvent(event.type, { + ...init, + button: event.button, + buttons: event.buttons, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + relatedTarget: event.relatedTarget, + screenX: event.screenX, + screenY: event.screenY, + shiftKey: event.shiftKey, + }) + } + + if (event instanceof FocusEvent) { + return new FocusEvent(event.type, { + ...init, + relatedTarget: event.relatedTarget, + }) + } + + return new Event(event.type, init) +} + +function replayHydrationEvents(id: string) { + const pendingEvents = replayEventsByGateId.get(id) + if (!pendingEvents?.length) return + + replayEventsByGateId.delete(id) + + for (const pendingEvent of pendingEvents) { + let replayTarget: Element | null = pendingEvent.marker + for (const index of pendingEvent.targetPath) { + replayTarget = replayTarget.children[index] ?? null + if (!replayTarget) break + } + + replayTarget ??= pendingEvent.marker + replayTarget.dispatchEvent(createReplayEvent(pendingEvent.event)) + } +} + +function getIntentMarkers(rootMarker: Element, event: Event) { + const target = event.target + if (!(target instanceof Element)) return [rootMarker] + + const closestMarker = target.closest(hydrateIdSelector) + let marker: Element | null = + closestMarker && rootMarker.contains(closestMarker) + ? closestMarker + : rootMarker + + const markers: Array = [] + while (marker) { + if (marker.hasAttribute(hydrateIdAttribute)) { + markers.push(marker) + } + if (marker === rootMarker) break + marker = marker.parentElement + } + + if (!markers.includes(rootMarker)) { + markers.push(rootMarker) + } + + return markers.reverse() +} + +function listenForIntent( + element: Element, + events: ReadonlyArray, + context: HydrationRuntimeContext, +) { + const onIntent = (event: Event) => { + const markers = getIntentMarkers(element, event) + if ( + context.delegated && + !markers.some( + (marker) => + marker.getAttribute(hydrateWhenAttribute) === interactionType, + ) + ) { + return + } + + markers.forEach((marker) => { + queueHydrationReplayEvent(marker, event) + resolveHydrationMarker(marker) + }) + } + let disposed = false + + events.forEach((eventName) => { + element.addEventListener(eventName, onIntent, true) + }) + + return () => { + if (disposed) return + disposed = true + events.forEach((eventName) => { + element.removeEventListener(eventName, onIntent, true) + }) + } +} + +function listenForPrefetchIntent( + element: Element, + events: ReadonlyArray, + prefetch: () => void, +) { + let disposed = false + + events.forEach((eventName) => { + element.addEventListener(eventName, prefetch, true) + }) + + return () => { + if (disposed) return + disposed = true + events.forEach((eventName) => { + element.removeEventListener(eventName, prefetch, true) + }) + } +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction( + options: InteractionHydrationOptions = {}, +): HydrationStrategy & HydrationPrefetchStrategy { + const events = normalizeInteractionEvents(options.events) + const eventKey = events.join(' ') + + return { + type: interactionType, + key: `${interactionType}:${eventKey}`, + setup: (context) => { + const element = context.element + if (!element) return + const listenerEvents = getIntentListenerEvents(element, events) + const cleanupIntent = listenerEvents.length + ? listenForIntent(element, listenerEvents, context) + : undefined + return () => { + cleanupIntent?.() + clearResolvedGateIdsInMarker(element) + } + }, + setupPrefetch: ({ element, prefetch }) => { + if (!element || !events.length) return + return listenForPrefetchIntent(element, events, prefetch) + }, + onHydrated: (id) => { + if (typeof globalThis.requestAnimationFrame === 'function') { + globalThis.requestAnimationFrame(() => replayHydrationEvents(id)) + } else { + globalThis.setTimeout(() => replayHydrationEvents(id), 0) + } + }, + getMarkerAttributes: () => + options.events === undefined + ? undefined + : { + [hydrateInteractionEventsAttribute]: eventKey, + }, + } +} diff --git a/packages/start-client-core/src/hydration/load.ts b/packages/start-client-core/src/hydration/load.ts new file mode 100644 index 00000000000..2c5e3b61e47 --- /dev/null +++ b/packages/start-client-core/src/hydration/load.ts @@ -0,0 +1,17 @@ +import type { HydrationPrefetchStrategy, HydrationStrategy } from './types' + +const loadType = 'load' + +const loadStrategy: HydrationStrategy & HydrationPrefetchStrategy = { + type: loadType, + key: loadType, + shouldDefer: () => false, + setupPrefetch: ({ prefetch }) => { + prefetch() + }, +} + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): HydrationStrategy & HydrationPrefetchStrategy { + return loadStrategy +} diff --git a/packages/start-client-core/src/hydration/media.ts b/packages/start-client-core/src/hydration/media.ts new file mode 100644 index 00000000000..dfff9a5b514 --- /dev/null +++ b/packages/start-client-core/src/hydration/media.ts @@ -0,0 +1,28 @@ +import type { HydrationPrefetchStrategy, HydrationStrategy } from './types' + +const mediaType = 'media' + +function listenForMedia(query: string, callback: () => void) { + if (!query) return + + const mediaQuery = window.matchMedia(query) + const onChange = () => { + if (mediaQuery.matches) callback() + } + mediaQuery.addEventListener('change', onChange) + onChange() + + return () => mediaQuery.removeEventListener('change', onChange) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function media( + query: string, +): HydrationStrategy & HydrationPrefetchStrategy { + return { + type: mediaType, + key: `${mediaType}:${query}`, + setup: ({ gate }) => listenForMedia(query, gate.resolve), + setupPrefetch: ({ prefetch }) => listenForMedia(query, prefetch), + } +} diff --git a/packages/start-client-core/src/hydration/never.ts b/packages/start-client-core/src/hydration/never.ts new file mode 100644 index 00000000000..e01611d97e1 --- /dev/null +++ b/packages/start-client-core/src/hydration/never.ts @@ -0,0 +1,16 @@ +import type { HydrationStrategy } from './types' + +const neverType = 'never' + +const neverStrategy: HydrationStrategy = { + type: neverType, + key: neverType, + shouldDefer: () => true, +} + +/* @__NO_SIDE_EFFECTS__ */ +function neverHydrate(): HydrationStrategy { + return neverStrategy +} + +export { neverHydrate as never } diff --git a/packages/start-client-core/src/hydration/runtime.ts b/packages/start-client-core/src/hydration/runtime.ts new file mode 100644 index 00000000000..c745f0c5771 --- /dev/null +++ b/packages/start-client-core/src/hydration/runtime.ts @@ -0,0 +1,137 @@ +import { hydrateIdAttribute, hydrateWhenAttribute } from './constants' +import type { HydrationRuntimeGate, HydrationWhen } from './types' + +const hydrateIdSelector = `[${hydrateIdAttribute}]` + +export type HydrationGateRecord = HydrationRuntimeGate & { + promise: Promise + consumers: number + resolveListeners: Set<() => void> +} + +const gateRegistry = new Map() +const resolvedGateIds = new Set() +const fallbackHtmlByGateId = new Map() + +export function createResolvedGate( + id: string, + when: HydrationWhen, +): HydrationGateRecord { + return { + id, + when, + promise: Promise.resolve(), + resolve: () => {}, + resolved: true, + consumers: 0, + resolveListeners: new Set<() => void>(), + } +} + +export function getOrCreateGate( + id: string, + when: HydrationWhen, +): HydrationGateRecord { + const existing = gateRegistry.get(id) + if (existing?.when === when) { + existing.consumers++ + return existing + } + + let resolvePromise!: () => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + const gate: HydrationGateRecord = { + id, + promise, + resolved: false, + consumers: 1, + when, + resolveListeners: new Set(), + resolve: () => { + if (gate.resolved) return + gate.resolved = true + resolvePromise() + gate.resolveListeners.forEach((listener) => listener()) + gate.resolveListeners.clear() + }, + } + + gateRegistry.set(id, gate) + if (when !== 'never' && resolvedGateIds.has(id)) { + resolvedGateIds.delete(id) + gate.resolve() + } + return gate +} + +export function releaseGate(gate: HydrationGateRecord) { + resolvedGateIds.delete(gate.id) + gate.consumers-- + if (gate.consumers > 0) return + if (gateRegistry.get(gate.id) === gate) { + gateRegistry.delete(gate.id) + fallbackHtmlByGateId.delete(gate.id) + gate.resolveListeners.clear() + } +} + +export function onGateResolve(gate: HydrationGateRecord, listener: () => void) { + if (gate.resolved) { + listener() + return () => {} + } + + gate.resolveListeners.add(listener) + return () => { + gate.resolveListeners.delete(listener) + } +} + +export function getMarkerGate(marker: Element) { + const id = marker.getAttribute(hydrateIdAttribute) + return id ? gateRegistry.get(id) : undefined +} + +export function resolveHydrationMarker(marker: Element) { + const id = marker.getAttribute(hydrateIdAttribute) + if (!id || marker.getAttribute(hydrateWhenAttribute) === 'never') { + return + } + + const gate = gateRegistry.get(id) + if (gate) { + if (gate.when !== 'never') { + gate.resolve() + } + return + } + + resolvedGateIds.add(id) +} + +export function clearResolvedGateIdsInMarker(marker: Element) { + const ownId = marker.getAttribute(hydrateIdAttribute) + if (ownId) { + resolvedGateIds.delete(ownId) + } + + marker.querySelectorAll(hydrateIdSelector).forEach((childMarker) => { + const childId = childMarker.getAttribute(hydrateIdAttribute) + if (childId) { + resolvedGateIds.delete(childId) + } + }) +} + +export function saveFallbackHtml(id: string, element: Element) { + if (!fallbackHtmlByGateId.has(id)) { + fallbackHtmlByGateId.set(id, element.innerHTML) + } +} + +export function getFallbackHtml(id: string) { + return fallbackHtmlByGateId.get(id) +} diff --git a/packages/start-client-core/src/hydration/types.ts b/packages/start-client-core/src/hydration/types.ts new file mode 100644 index 00000000000..b12eeaeef3e --- /dev/null +++ b/packages/start-client-core/src/hydration/types.ts @@ -0,0 +1,63 @@ +export type HydrationWhen = + | 'load' + | 'idle' + | 'visible' + | 'media' + | 'interaction' + | 'condition' + | 'never' + +export type HydrationInteractionEvent = + | 'auxclick' + | 'click' + | 'contextmenu' + | 'dblclick' + | 'focusin' + | 'keydown' + | 'keyup' + | 'mousedown' + | 'mouseenter' + | 'mouseover' + | 'mouseup' + | 'pointerdown' + | 'pointerenter' + | 'pointerover' + | 'pointerup' + +export type HydrationInteractionEvents = + | HydrationInteractionEvent + | ReadonlyArray + +export type HydrationMarkerAttributes = Record + +export type HydrationRuntimeGate = { + id: string + when: HydrationWhen + resolved: boolean + resolve: () => void +} + +export type HydrationRuntimeContext = { + element: Element | null + gate: HydrationRuntimeGate + delegated?: boolean +} + +export type HydrationPrefetchContext = { + element: Element | null + prefetch: () => void +} + +export type HydrationStrategy = { + type: HydrationWhen + key: string + shouldDefer?: () => boolean + setup?: (context: HydrationRuntimeContext) => void | (() => void) + setupPrefetch?: (context: HydrationPrefetchContext) => void | (() => void) + onHydrated?: (id: string) => void + getMarkerAttributes?: () => HydrationMarkerAttributes | undefined +} + +export type HydrationPrefetchStrategy = HydrationStrategy & { + type: Exclude +} diff --git a/packages/start-client-core/src/hydration/visible.ts b/packages/start-client-core/src/hydration/visible.ts new file mode 100644 index 00000000000..21a145a26bd --- /dev/null +++ b/packages/start-client-core/src/hydration/visible.ts @@ -0,0 +1,225 @@ +import type { HydrationPrefetchStrategy, HydrationStrategy } from './types' + +const visibleType = 'visible' + +export type VisibleHydrationOptions = { + rootMargin?: string + threshold?: number | Array +} + +type VisibleObserverEntry = { + key: string + observer: IntersectionObserver + elements: Map void>> +} + +const observerRegistry = new Map() +let visibleFallbackListenersInstalled = false +let visibleFallbackCheckHandle: number | undefined +let visibleFallbackCheckType: 'animation-frame' | 'timeout' | undefined = + undefined + +function hasIntersectionObserver() { + return ( + typeof ( + globalThis as unknown as { + IntersectionObserver?: typeof IntersectionObserver + } + ).IntersectionObserver === 'function' + ) +} + +function getVisibleKey(rootMargin: string, threshold: number | Array) { + return `${rootMargin}|${ + Array.isArray(threshold) ? threshold.join(',') : String(threshold) + }` +} + +function getObserver(rootMargin: string, threshold: number | Array) { + const key = getVisibleKey(rootMargin, threshold) + const existing = observerRegistry.get(key) + if (existing) return existing + + const entry: VisibleObserverEntry = { + key, + elements: new Map void>>(), + observer: new IntersectionObserver( + (entries) => { + for (const observerEntry of entries) { + if (!observerEntry.isIntersecting) continue + resolveVisibleElement(entry, observerEntry.target) + } + }, + { rootMargin, threshold }, + ), + } + + observerRegistry.set(key, entry) + return entry +} + +function cleanupVisibleObserverEntry(observerEntry: VisibleObserverEntry) { + if (observerEntry.elements.size > 0) return + observerEntry.observer.disconnect() + observerRegistry.delete(observerEntry.key) + removeVisibleFallbackListeners() +} + +function unobserveVisibleCallback( + observerEntry: VisibleObserverEntry, + element: Element, + callback: () => void, +) { + const currentCallbacks = observerEntry.elements.get(element) + currentCallbacks?.delete(callback) + if (currentCallbacks?.size === 0) { + observerEntry.elements.delete(element) + observerEntry.observer.unobserve(element) + } + cleanupVisibleObserverEntry(observerEntry) +} + +function resolveVisibleElement( + observerEntry: VisibleObserverEntry, + element: Element, +) { + const callbacks = observerEntry.elements.get(element) + if (!callbacks) return + + callbacks.forEach((callback) => callback()) + observerEntry.elements.delete(element) + observerEntry.observer.unobserve(element) + cleanupVisibleObserverEntry(observerEntry) +} + +function isElementInViewport(element: Element) { + const rect = element.getBoundingClientRect() + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight + const viewportWidth = + window.innerWidth || document.documentElement.clientWidth + + return ( + rect.bottom > 0 && + rect.right > 0 && + rect.top < viewportHeight && + rect.left < viewportWidth + ) +} + +function checkVisibleFallbacks() { + visibleFallbackCheckHandle = undefined + visibleFallbackCheckType = undefined + + observerRegistry.forEach((observerEntry) => { + observerEntry.elements.forEach((_callbacks, element) => { + if (!isElementInViewport(element)) return + resolveVisibleElement(observerEntry, element) + }) + }) +} + +function scheduleVisibleFallbackCheck() { + if ( + visibleFallbackCheckHandle !== undefined || + typeof window === 'undefined' + ) { + return + } + + if (typeof window.requestAnimationFrame === 'function') { + visibleFallbackCheckType = 'animation-frame' + visibleFallbackCheckHandle = window.requestAnimationFrame( + checkVisibleFallbacks, + ) + return + } + + visibleFallbackCheckType = 'timeout' + visibleFallbackCheckHandle = window.setTimeout(checkVisibleFallbacks, 16) +} + +function cancelVisibleFallbackCheck() { + if ( + visibleFallbackCheckHandle === undefined || + typeof window === 'undefined' + ) { + return + } + + if (visibleFallbackCheckType === 'animation-frame') { + window.cancelAnimationFrame(visibleFallbackCheckHandle) + } else { + window.clearTimeout(visibleFallbackCheckHandle) + } + + visibleFallbackCheckHandle = undefined + visibleFallbackCheckType = undefined +} + +function installVisibleFallbackListeners() { + if (visibleFallbackListenersInstalled || typeof window === 'undefined') { + return + } + visibleFallbackListenersInstalled = true + window.addEventListener('scroll', scheduleVisibleFallbackCheck, { + capture: true, + passive: true, + }) + window.addEventListener('resize', scheduleVisibleFallbackCheck) +} + +function removeVisibleFallbackListeners() { + if (!visibleFallbackListenersInstalled || observerRegistry.size > 0) return + visibleFallbackListenersInstalled = false + window.removeEventListener('scroll', scheduleVisibleFallbackCheck, true) + window.removeEventListener('resize', scheduleVisibleFallbackCheck) + cancelVisibleFallbackCheck() +} + +function observeVisible( + element: Element | null, + callback: () => void, + rootMargin: string, + threshold: number | Array, +) { + if (!element || !hasIntersectionObserver()) { + callback() + return + } + + const observerEntry = getObserver(rootMargin, threshold) + let callbacks = observerEntry.elements.get(element) + if (!callbacks) { + callbacks = new Set() + observerEntry.elements.set(element, callbacks) + observerEntry.observer.observe(element) + } + callbacks.add(callback) + installVisibleFallbackListeners() + + if (isElementInViewport(element)) { + resolveVisibleElement(observerEntry, element) + } else { + scheduleVisibleFallbackCheck() + } + + return () => unobserveVisibleCallback(observerEntry, element, callback) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options: VisibleHydrationOptions = {}, +): HydrationStrategy & HydrationPrefetchStrategy { + const rootMargin = options.rootMargin ?? '600px' + const threshold = options.threshold ?? 0 + + return { + type: visibleType, + key: `${visibleType}:${getVisibleKey(rootMargin, threshold)}`, + setup: ({ element, gate }) => + observeVisible(element, gate.resolve, rootMargin, threshold), + setupPrefetch: ({ element, prefetch }) => + observeVisible(element, prefetch, rootMargin, threshold), + } +} diff --git a/packages/start-client-core/vite.config.ts b/packages/start-client-core/vite.config.ts index e893ba2c8da..0f2cb644473 100644 --- a/packages/start-client-core/vite.config.ts +++ b/packages/start-client-core/vite.config.ts @@ -20,6 +20,9 @@ export default mergeConfig( './src/index.tsx', './src/client/index.ts', './src/client-rpc/index.ts', + './src/hydration/constants.ts', + './src/hydration.ts', + './src/hydration/runtime.ts', './src/fake-entries/start.ts', './src/fake-entries/router.ts', './src/fake-entries/plugin-adapters.ts', diff --git a/packages/start-plugin-core/src/hydrate-when-transform.ts b/packages/start-plugin-core/src/hydrate-when-transform.ts new file mode 100644 index 00000000000..09ad53adecc --- /dev/null +++ b/packages/start-plugin-core/src/hydrate-when-transform.ts @@ -0,0 +1,916 @@ +import { relative } from 'node:path' +import crypto from 'node:crypto' +import babel from '@babel/core' +import * as t from '@babel/types' +import { + buildDeclarationMap, + buildDependencyGraph, + collectIdentifiersFromNode, + collectLocalBindingsFromStatement, + deadCodeElimination, + expandTransitively, + findReferencedIdentifiers, + generateFromAst, + parseAst, + removeModuleLevelBindings, + retainModuleLevelDeclarations, + stripUnreferencedTopLevelExpressionStatements, + unwrapExportedDeclarations, +} from '@tanstack/router-utils' +import { tssHydrate } from './hydration-constants' +import { cleanId, codeFrameError } from './start-compiler/utils' +import type { StartCompilerPlugin } from './types' + +type HydrationImport = { + hydrateLocalName: string + source: string + interactionLocalNames: Set +} + +const hydrateImportSources = new Set([ + '@tanstack/react-start', + '@tanstack/solid-start', +]) + +/** + * Detection pattern used by the transform code filter to pre-scan files for + * `` JSX before any AST parsing happens. + */ +const HYDRATE_DETECTION_PATTERN = /\bHydrate\b/ + +function createBoundaryId(root: string, sourceId: string) { + const normalized = relative(root, sourceId).replaceAll('\\', '/') + const label = + normalized + .replace(/\.[cm]?[jt]sx?$/, '') + .replace(/[^a-zA-Z0-9_$]+/g, '_') + .replace(/^_+|_+$/g, '') || 'Hydrate' + + return (index: number) => { + const hash = crypto + .createHash('sha1') + .update(normalized) + .update(':') + .update(String(index)) + .digest('hex') + .slice(0, 10) + + return `${label}_${hash}` + } +} + +function getJSXElementName(node: t.JSXElement) { + const name = node.openingElement.name + return t.isJSXIdentifier(name) ? name.name : undefined +} + +function getJSXAttribute(node: t.JSXOpeningElement, name: string) { + for (const item of node.attributes) { + if (t.isJSXAttribute(item) && t.isJSXIdentifier(item.name, { name })) { + return item + } + } + + return undefined +} + +function getBooleanProp(node: t.JSXOpeningElement, name: string) { + const attr = getJSXAttribute(node, name) + if (!attr) return undefined + if (!attr.value) return true + if (t.isStringLiteral(attr.value)) return attr.value.value !== 'false' + if (t.isJSXExpressionContainer(attr.value)) { + if (t.isBooleanLiteral(attr.value.expression)) { + return attr.value.expression.value + } + } + return undefined +} + +function isInteractionCall( + node: t.Node | null | undefined, + interactionLocalNames: Set, +) { + if (!t.isCallExpression(node)) return false + return ( + t.isIdentifier(node.callee) && interactionLocalNames.has(node.callee.name) + ) +} + +function getWhenExpression(node: t.JSXOpeningElement) { + const when = getJSXAttribute(node, 'when') + if (!when?.value || !t.isJSXExpressionContainer(when.value)) return undefined + return when.value.expression +} + +const hydrateBoundaryIndexParam = `${tssHydrate}-index` + +function parseHydrateVirtualId(id: string) { + const queryIndex = id.indexOf('?') + const sourceId = cleanId(queryIndex === -1 ? id : id.slice(0, queryIndex)) + if (queryIndex === -1) { + return { sourceId, splitId: null, boundaryIndex: -1 } + } + + const rawQuery = id.slice(queryIndex + 1) + const params = new URLSearchParams(rawQuery) + const splitId = params.get(tssHydrate) + const rawIndex = params.get(hydrateBoundaryIndexParam) + const boundaryIndex = rawIndex === null ? -1 : Number(rawIndex) + + return { + sourceId, + splitId, + boundaryIndex: Number.isInteger(boundaryIndex) ? boundaryIndex : -1, + } +} + +function createHydrateImportId(sourceId: string, id: string, index: number) { + const params = new URLSearchParams() + params.set(tssHydrate, id) + params.set(hydrateBoundaryIndexParam, String(index)) + return `${sourceId}?${params.toString()}` +} + +function upsertSplitId(node: t.JSXOpeningElement, splitId: string) { + const existing = getJSXAttribute(node, 'splitId') + + if (existing) { + existing.value = t.stringLiteral(splitId) + return + } + + node.attributes.push( + t.jsxAttribute(t.jsxIdentifier('splitId'), t.stringLiteral(splitId)), + ) +} + +function isObjectPropertyName( + property: t.ObjectMethod | t.ObjectProperty, + name: string, +) { + if (t.isIdentifier(property.key) && !property.computed) { + return property.key.name === name + } + + return t.isStringLiteral(property.key) && property.key.value === name +} + +function isReferenceInsideNode( + referencePath: babel.NodePath, + node: t.Node, +): boolean { + if (referencePath.node === node) return true + return Boolean(referencePath.findParent((parent) => parent.node === node)) +} + +function removeBindingDeclaration( + binding: NonNullable>, +) { + if (binding.path.isVariableDeclarator()) { + const declarationPath = binding.path.parentPath + if ( + declarationPath.isVariableDeclaration() && + declarationPath.node.declarations.length === 1 + ) { + declarationPath.remove() + return + } + + binding.path.remove() + return + } + + if ( + binding.path.isImportSpecifier() || + binding.path.isImportDefaultSpecifier() || + binding.path.isImportNamespaceSpecifier() + ) { + const importPath = binding.path.parentPath + if ( + importPath.isImportDeclaration() && + importPath.node.specifiers.length === 1 + ) { + importPath.remove() + return + } + + binding.path.remove() + return + } + + binding.path.remove() +} + +function stripBindingsOnlyReferencedBy( + path: babel.NodePath, + node: t.Node, + seen = new Set(), +) { + for (const name of collectIdentifiersFromNode(node)) { + if (seen.has(name)) continue + const binding = path.scope.getBinding(name) + if (!binding?.constant) continue + if (binding.referencePaths.length === 0) continue + if ( + !binding.referencePaths.every((referencePath) => + isReferenceInsideNode(referencePath, node), + ) + ) { + continue + } + + seen.add(name) + + const bindingNode = binding.path.node + if (t.isVariableDeclarator(bindingNode) && bindingNode.init) { + stripBindingsOnlyReferencedBy(path, bindingNode.init, seen) + } else if ( + t.isFunctionDeclaration(bindingNode) || + t.isClassDeclaration(bindingNode) + ) { + stripBindingsOnlyReferencedBy(path, bindingNode, seen) + } + + removeBindingDeclaration(binding) + } +} + +function stripObjectExpressionProperty( + path: babel.NodePath, + node: t.ObjectExpression, + name: string, +) { + let modified = false + + node.properties = node.properties.filter((property) => { + if ( + (t.isObjectMethod(property) || t.isObjectProperty(property)) && + isObjectPropertyName(property, name) + ) { + stripBindingsOnlyReferencedBy( + path, + t.isObjectProperty(property) ? property.value : property.body, + ) + modified = true + return false + } + + return true + }) + + return modified +} + +function stripSingleUseObjectBindingProperty( + path: babel.NodePath, + identifier: t.Identifier, + name: string, +) { + const binding = path.scope.getBinding(identifier.name) + if (!binding?.constant) return false + if (binding.referencePaths.length !== 1) return false + if (binding.referencePaths[0]?.node !== identifier) return false + if (!binding.path.isVariableDeclarator()) return false + if (!t.isObjectExpression(binding.path.node.init)) return false + + return stripObjectExpressionProperty(path, binding.path.node.init, name) +} + +function stripJSXAttribute(path: babel.NodePath, name: string) { + const node = path.node.openingElement + let modified = false + + node.attributes = node.attributes.filter((item) => { + if (t.isJSXAttribute(item) && t.isJSXIdentifier(item.name, { name })) { + if (item.value) { + stripBindingsOnlyReferencedBy(path, item.value) + } + modified = true + return false + } + + if (t.isJSXSpreadAttribute(item) && t.isObjectExpression(item.argument)) { + if (stripObjectExpressionProperty(path, item.argument, name)) { + modified = true + } + return item.argument.properties.length > 0 + } + + if (t.isJSXSpreadAttribute(item) && t.isIdentifier(item.argument)) { + if (stripSingleUseObjectBindingProperty(path, item.argument, name)) { + modified = true + } + } + + return true + }) + + return modified +} + +function throwBoundaryError( + code: string, + path: babel.NodePath, + message: string, +): never { + if (path.node.loc) { + throw codeFrameError(code, path.node.loc, message) + } + throw new Error(message) +} + +function inspectSplitBoundary(options: { + code: string + path: babel.NodePath + validate?: boolean + collectCaptured?: boolean + nestedHydrate?: { + localName: string + interactionLocalNames: Set + } +}) { + const { path } = options + const capturedNames = options.collectCaptured ? new Set() : undefined + const nestedHydrate = options.nestedHydrate + let nestedBoundaryCount = 0 + let hasNestedInteraction = false + + if (options.validate) { + for (const child of path.node.children) { + if ( + t.isJSXExpressionContainer(child) && + (t.isFunctionExpression(child.expression) || + t.isArrowFunctionExpression(child.expression)) + ) { + throwBoundaryError( + options.code, + path, + 'Hydrate cannot code-split function-as-children. Use split={false} for this boundary.', + ) + } + } + } + + const validateVisitors = options.validate + ? { + CallExpression(callPath: babel.NodePath) { + if (!t.isIdentifier(callPath.node.callee)) return + if (!/^use[A-Z0-9]/.test(callPath.node.callee.name)) return + + throwBoundaryError( + options.code, + path, + 'Hydrate cannot code-split JSX that calls hooks during render. Move the hook call into a child component or use split={false}.', + ) + }, + ThisExpression(thisPath: babel.NodePath) { + void thisPath + throwBoundaryError( + options.code, + path, + 'Hydrate cannot code-split JSX that captures this.', + ) + }, + Super(superPath: babel.NodePath) { + void superPath + throwBoundaryError( + options.code, + path, + 'Hydrate cannot code-split JSX that captures super.', + ) + }, + } + : {} + + const nestedHydrateVisitors = nestedHydrate + ? { + JSXElement(nestedPath: babel.NodePath) { + if (getJSXElementName(nestedPath.node) !== nestedHydrate.localName) { + return + } + + const split = getBooleanProp(nestedPath.node.openingElement, 'split') + if (split === false) return + + nestedBoundaryCount++ + if ( + isInteractionCall( + getWhenExpression(nestedPath.node.openingElement), + nestedHydrate.interactionLocalNames, + ) + ) { + hasNestedInteraction = true + } + }, + } + : {} + + const captureVisitors = capturedNames + ? { + Identifier(identifierPath: babel.NodePath) { + if ( + identifierPath.findParent( + (parent) => parent.node === path.node.openingElement, + ) + ) { + return + } + + const parent = identifierPath.parent + if ( + t.isJSXOpeningElement(parent) || + t.isJSXClosingElement(parent) || + (t.isObjectProperty(parent, { key: identifierPath.node }) && + !parent.computed && + !parent.shorthand) || + (t.isMemberExpression(parent, { + property: identifierPath.node, + }) && + !parent.computed) + ) { + return + } + + const binding = identifierPath.scope.getBinding( + identifierPath.node.name, + ) + if (!binding) return + if (t.isProgram(binding.scope.block)) return + if ( + path.node === binding.scope.block || + path.isAncestor(binding.path) + ) + return + + capturedNames.add(identifierPath.node.name) + }, + JSXIdentifier(identifierPath: babel.NodePath) { + if ( + identifierPath.findParent( + (parent) => parent.node === path.node.openingElement, + ) + ) { + return + } + + if (identifierPath.parentKey !== 'name') return + const name = identifierPath.node.name + if (!/^[A-Z]/.test(name)) return + const binding = identifierPath.scope.getBinding(name) + if (!binding) return + if (t.isProgram(binding.scope.block)) return + + capturedNames.add(name) + }, + } + : {} + + path.traverse({ + ...validateVisitors, + ...nestedHydrateVisitors, + ...captureVisitors, + }) + + return { + captured: capturedNames ? [...capturedNames].sort() : [], + nestedBoundaryCount, + hasNestedInteraction, + } +} + +function getHydrateImport(ast: t.File) { + let hydrateImport: HydrationImport | undefined + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue + if (hydrateImportSources.has(node.source.value)) { + for (const specifier of node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name: 'Hydrate' }) + ) { + hydrateImport = { + hydrateLocalName: specifier.local.name, + source: node.source.value, + interactionLocalNames: + hydrateImport?.interactionLocalNames ?? new Set(), + } + } + } + continue + } + + if ( + node.source.value !== '@tanstack/react-start/hydration' && + node.source.value !== '@tanstack/solid-start/hydration' + ) { + continue + } + + for (const specifier of node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name: 'interaction' }) + ) { + hydrateImport ??= { + hydrateLocalName: '', + source: '', + interactionLocalNames: new Set(), + } + hydrateImport.interactionLocalNames.add(specifier.local.name) + } + } + } + + return hydrateImport?.hydrateLocalName ? hydrateImport : undefined +} + +function getOrAddInteractionImport( + programPath: babel.NodePath, + source: string, +) { + const importSource = `${source}/hydration` + for (const node of programPath.node.body) { + if (!t.isImportDeclaration(node) || node.source.value !== importSource) { + continue + } + for (const specifier of node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name: 'interaction' }) + ) { + return specifier.local + } + } + const interactionIdent = + programPath.scope.generateUidIdentifier('interaction') + node.specifiers.push( + t.importSpecifier(interactionIdent, t.identifier('interaction')), + ) + return interactionIdent + } + + const interactionIdent = + programPath.scope.generateUidIdentifier('interaction') + programPath.unshiftContainer( + 'body', + t.importDeclaration( + [t.importSpecifier(interactionIdent, t.identifier('interaction'))], + t.stringLiteral(importSource), + ), + ) + return interactionIdent +} + +function addClientImports( + programPath: babel.NodePath, + source: string, +) { + const lazyIdent = programPath.scope.generateUidIdentifier( + 'lazyHydratedComponent', + ) + programPath.unshiftContainer('body', [ + t.importDeclaration( + [t.importSpecifier(lazyIdent, t.identifier('lazyHydratedComponent'))], + t.stringLiteral(source), + ), + ]) + return lazyIdent +} + +function transformHydrateAst(options: { + ast: t.File + code: string + id: string + root: string + env: 'client' | 'server' + indexOffset?: number +}) { + if (!options.code.includes('Hydrate')) return null + + const hydrateImport = getHydrateImport(options.ast) + if (!hydrateImport) return null + const { hydrateLocalName: localName } = hydrateImport + const sourceId = cleanId(options.id) + const getBoundaryId = createBoundaryId(options.root, sourceId) + + let nextBoundaryIndex = options.indexOffset ?? 0 + const state = { modified: false } + let lazyIdent: t.Identifier | undefined + let interactionIdent: t.Identifier | undefined + + babel.traverse(options.ast, { + Program(programPath) { + programPath.traverse({ + JSXElement(path) { + if (getJSXElementName(path.node) !== localName) return + + if (options.env === 'server' && stripJSXAttribute(path, 'fallback')) { + state.modified = true + } + + const split = getBooleanProp(path.node.openingElement, 'split') + if (split === false) return + + const boundaryInspection = inspectSplitBoundary({ + code: options.code, + path, + validate: true, + collectCaptured: options.env === 'client', + ...(options.env === 'client' + ? { + nestedHydrate: { + localName, + interactionLocalNames: hydrateImport.interactionLocalNames, + }, + } + : {}), + }) + + const index = nextBoundaryIndex + const needsDelegatedInteraction = + options.env === 'client' && + boundaryInspection.hasNestedInteraction && + !isInteractionCall( + getWhenExpression(path.node.openingElement), + hydrateImport.interactionLocalNames, + ) + nextBoundaryIndex += 1 + boundaryInspection.nestedBoundaryCount + const id = getBoundaryId(index) + const exportName = `Hydrate_${index}` + + upsertSplitId(path.node.openingElement, id) + if (needsDelegatedInteraction) { + interactionIdent ??= getOrAddInteractionImport( + programPath, + hydrateImport.source, + ) + path.node.openingElement.attributes.push( + t.jsxAttribute( + t.jsxIdentifier('__hydrate'), + t.jsxExpressionContainer( + t.callExpression(interactionIdent, []), + ), + ), + ) + } + state.modified = true + + if (options.env === 'server') return + + if (!lazyIdent) { + lazyIdent = addClientImports(programPath, hydrateImport.source) + } + + const componentIdent = + programPath.scope.generateUidIdentifier(exportName) + const preloadIdent = programPath.scope.generateUidIdentifier( + `${exportName}_preload`, + ) + programPath.unshiftContainer('body', [ + t.variableDeclaration('const', [ + t.variableDeclarator( + componentIdent, + t.callExpression(lazyIdent, [ + t.arrowFunctionExpression( + [], + t.callExpression(t.import(), [ + t.stringLiteral( + createHydrateImportId(sourceId, id, index), + ), + ]), + ), + t.stringLiteral(exportName), + ]), + ), + t.variableDeclarator( + preloadIdent, + t.memberExpression(componentIdent, t.identifier('preload')), + ), + ]), + ]) + path.node.openingElement.attributes.push( + t.jsxAttribute( + t.jsxIdentifier('preload'), + t.jsxExpressionContainer(preloadIdent), + ), + ) + + path.node.children = [ + t.jsxText('\n'), + t.jsxExpressionContainer( + t.jsxElement( + t.jsxOpeningElement( + t.jsxIdentifier(componentIdent.name), + boundaryInspection.captured.map((name) => + t.jsxAttribute( + t.jsxIdentifier(name), + t.jsxExpressionContainer(t.identifier(name)), + ), + ), + true, + ), + null, + [], + true, + ), + ), + t.jsxText('\n'), + ] + path.skip() + }, + }) + }, + }) + + if (!state.modified) return null + + return true +} + +function loadHydrateVirtualModule(options: { + id: string + root: string + code: string +}) { + const { sourceId, splitId, boundaryIndex } = parseHydrateVirtualId(options.id) + if (!splitId || boundaryIndex < 0) return null + const getBoundaryId = createBoundaryId(options.root, sourceId) + + const ast = parseAst({ code: options.code, sourceFilename: sourceId }) + const hydrateImport = getHydrateImport(ast) + if (!hydrateImport) return null + const { hydrateLocalName: localName } = hydrateImport + + let target: t.JSXElement | undefined + let targetIndex = -1 + let targetCaptured: Array = [] + let index = 0 + + babel.traverse(ast, { + JSXElement(path) { + if (getJSXElementName(path.node) !== localName) return + const split = getBooleanProp(path.node.openingElement, 'split') + if (split === false) return + + if (index === boundaryIndex) { + const id = getBoundaryId(index) + if (id !== splitId) { + path.stop() + return + } + targetCaptured = inspectSplitBoundary({ + code: options.code, + path, + collectCaptured: true, + }).captured + target = t.cloneNode(path.node, true) + targetIndex = index + path.stop() + return + } + index++ + }, + }) + + if (!target || targetIndex < 0) return null + + const children = target.children + const exportName = `Hydrate_${targetIndex}` + const refIdents = findReferencedIdentifiers(ast) + + removeModuleLevelBindings(ast, new Set(['Route'])) + const localBindings = new Set() + for (const node of ast.program.body) { + collectLocalBindingsFromStatement(node, localBindings) + } + + const keepBindings = new Set() + const fragment = t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + children, + ) + for (const name of collectIdentifiersFromNode(fragment)) { + if (localBindings.has(name)) { + keepBindings.add(name) + } + } + + if (keepBindings.size > 0) { + expandTransitively( + keepBindings, + buildDependencyGraph(buildDeclarationMap(ast), localBindings), + ) + } + + retainModuleLevelDeclarations(ast, keepBindings) + unwrapExportedDeclarations(ast) + + ast.program.body.push( + t.exportNamedDeclaration( + t.functionDeclaration( + t.identifier(exportName), + [ + t.objectPattern( + targetCaptured.map((name) => + t.objectProperty( + t.identifier(name), + t.identifier(name), + false, + true, + ), + ), + ), + ], + t.blockStatement([t.returnStatement(fragment)]), + ), + ), + ) + + deadCodeElimination(ast, refIdents) + stripUnreferencedTopLevelExpressionStatements(ast) + + const result = generateFromAst(ast, { + sourceMaps: true, + sourceFileName: options.id, + filename: options.id, + }) + return result +} + +export function createHydrateCompilerPlugin(): StartCompilerPlugin { + const sourcesByEnvironment = new Map>() + + const getEnvironmentSources = (envName: string) => { + let sources = sourcesByEnvironment.get(envName) + if (!sources) { + sources = new Map() + sourcesByEnvironment.set(envName, sources) + } + return sources + } + + const setSource = (envName: string, id: string, code: string) => { + getEnvironmentSources(envName).set(cleanId(id), code) + } + + const getSource = (envName: string, id: string) => + sourcesByEnvironment.get(envName)?.get(cleanId(id)) + + const deleteSource = (envName: string, id: string) => { + sourcesByEnvironment.get(envName)?.delete(cleanId(id)) + } + + return { + name: 'tanstack-start-core:hydrate', + detect: HYDRATE_DETECTION_PATTERN, + virtualModuleIdPattern: new RegExp(`[?&]${tssHydrate}=`), + transformAst(context) { + const virtualModule = parseHydrateVirtualId(context.id) + const indexOffset = + virtualModule.boundaryIndex < 0 + ? undefined + : virtualModule.boundaryIndex + 1 + const result = transformHydrateAst({ + ast: context.ast, + code: context.code, + id: context.id, + root: context.root, + env: context.env, + indexOffset, + }) + + if (result && virtualModule.boundaryIndex < 0) { + setSource(context.envName, context.id, context.code) + } + + return !!result + }, + loadVirtualModule(context) { + const virtualModule = parseHydrateVirtualId(context.id) + if (!virtualModule.splitId || virtualModule.boundaryIndex < 0) { + return null + } + + const code = + context.code ?? getSource(context.envName, virtualModule.sourceId) + + if (code === undefined) { + throw new Error( + `Missing Hydrate source for virtual module ${context.id}. The parent module must be transformed before its Hydrate child chunk is loaded.`, + ) + } + + return loadHydrateVirtualModule({ + code, + id: context.id, + root: context.root, + }) + }, + invalidateModule(context) { + deleteSource(context.envName, context.id) + }, + } +} diff --git a/packages/start-plugin-core/src/hydration-constants.ts b/packages/start-plugin-core/src/hydration-constants.ts new file mode 100644 index 00000000000..b79c20ffd3d --- /dev/null +++ b/packages/start-plugin-core/src/hydration-constants.ts @@ -0,0 +1 @@ +export const tssHydrate = 'tss-hydrate' diff --git a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts index b349b68040d..60244fdca98 100644 --- a/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts +++ b/packages/start-plugin-core/src/rsbuild/normalized-client-build.ts @@ -1,4 +1,5 @@ import { tsrSplit } from '@tanstack/router-plugin' +import { tssHydrate } from '../hydration-constants' import { getCssAssetSource } from '../start-manifest-plugin/inlineCss' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' @@ -57,6 +58,39 @@ function getRouteFilePathsFromModules( return routeFilePaths ?? [] } +function getHydrationIdsFromModules( + modules: Array, +): Array { + let hydrationIds: Array | undefined + let seen: Set | undefined + + for (const mod of modules) { + const identifier = mod.identifier() + const lastBangIndex = identifier.lastIndexOf('!') + const resourcePart = + lastBangIndex >= 0 ? identifier.slice(lastBangIndex + 1) : identifier + + const queryIndex = resourcePart.indexOf('?') + if (queryIndex < 0) continue + + const query = resourcePart.slice(queryIndex + 1) + if (!query.includes(tssHydrate)) continue + + const hydrationId = new URLSearchParams(query).get(tssHydrate) + if (!hydrationId || seen?.has(hydrationId)) continue + + if (!hydrationIds || !seen) { + hydrationIds = [] + seen = new Set() + } + + hydrationIds.push(hydrationId) + seen.add(hydrationId) + } + + return hydrationIds ?? [] +} + /** * Returns true for Rspack/webpack HMR runtime chunks that should never be * surfaced to the Start manifest. These files are emitted on every rebuild @@ -186,6 +220,7 @@ export function normalizeRspackClientBuild( for (const chunk of compilation.chunks) { const modules = compilation.chunkGraph.getChunkModules(chunk) const routeFilePaths = getRouteFilePathsFromModules(modules) + const hydrationIds = getHydrationIdsFromModules(modules) const cssFiles: Array = [] const seenCssFiles = new Set() @@ -242,6 +277,7 @@ export function normalizeRspackClientBuild( dynamicImports, css: [], routeFilePaths, + hydrationIds, } chunksByFileName.set(file, normalizedChunk) diff --git a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts index 066d5b28676..d0ef77e34bb 100644 --- a/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts +++ b/packages/start-plugin-core/src/rsbuild/start-compiler-host.ts @@ -5,15 +5,19 @@ import { detectKindsInCode } from '../start-compiler/compiler' import { getTransformCodeFilterForEnv } from '../start-compiler/config' import { createStartCompiler, + loadCompilerVirtualModule, matchesCodeFilters, mergeServerFnsById, } from '../start-compiler/host' import { cleanId } from '../start-compiler/utils' +import { createHydrateCompilerPlugin } from '../hydrate-when-transform' import { RSBUILD_ENVIRONMENT_NAMES } from './planning' import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, + StartCompilerPlugin, + StartCompilerTransformResult, } from '../types' import type { DevServerFnModuleSpecifierEncoder, @@ -27,7 +31,7 @@ type RsbuildTransformContext = Parameters< type RsbuildInputFileSystem = NonNullable /** - * Rsbuild dev server fn ref strategy: uses file:// URLs for absolute paths. + * Rsbuild dev server fn ref when: uses file:// URLs for absolute paths. * These are directly importable by Node's ESM VM runner without any bundler * path conventions (unlike Vite's /@id/ prefix). */ @@ -40,6 +44,7 @@ export interface StartCompilerHostOptions { providerEnvName: string generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined + compilerPlugins?: Array | undefined serverFnProviderModuleDirectives?: ReadonlyArray | undefined serverFnsById?: Record onServerFnsByIdChange?: () => void @@ -69,6 +74,10 @@ export function registerStartCompilerTransforms( mergeServerFnsById(serverFnsById, d) opts.onServerFnsByIdChange?.() } + const compilerPlugins = [ + createHydrateCompilerPlugin(), + ...(opts.compilerPlugins ?? []), + ] const isDev = api.context.action === 'dev' const mode = isDev ? 'dev' : 'build' @@ -83,9 +92,12 @@ export function registerStartCompilerTransforms( // Pre-compute code filter patterns per environment type const codeFilters: Record<'client' | 'server', Array> = { - client: getTransformCodeFilterForEnv('client'), + client: getTransformCodeFilterForEnv('client', { + compilerPlugins, + }), server: getTransformCodeFilterForEnv('server', { compilerTransforms: opts.compilerTransforms, + compilerPlugins, }), } @@ -109,17 +121,36 @@ export function registerStartCompilerTransforms( async (ctx: RsbuildTransformContext) => { return transformContextStorage.run(ctx, async () => { const code = ctx.code + let nextCode = code + let previousResult: { + code: string + map: StartCompilerTransformResult['map'] + } | null = null const id = ctx.resourcePath + (ctx.resourceQuery || '') + const root = getRoot() + + const virtualResult = loadCompilerVirtualModule(compilerPlugins, { + code, + id, + root, + env: env.type, + envName: env.name, + }) + if (virtualResult) { + nextCode = virtualResult.code + previousResult = { + code: virtualResult.code, + map: virtualResult.map ?? null, + } + } // Quick string-level check: does this file contain any patterns for this env? - if (!matchesCodeFilters(code, envCodeFilters)) { - return code + if (!matchesCodeFilters(nextCode, envCodeFilters)) { + return previousResult ?? nextCode } let compiler = compilers.get(env.name) if (!compiler) { - const root = getRoot() - compiler = createStartCompiler({ env: env.type, envName: env.name, @@ -129,6 +160,7 @@ export function registerStartCompilerTransforms( providerEnvName: opts.providerEnvName, generateFunctionId: opts.generateFunctionId, compilerTransforms, + compilerPlugins, serverFnProviderModuleDirectives, onServerFnsById, getKnownServerFns: () => serverFnsById, @@ -197,19 +229,23 @@ export function registerStartCompilerTransforms( compilers.set(env.name, compiler) } - const detectedKinds = detectKindsInCode(code, env.type, { + const detectedKinds = detectKindsInCode(nextCode, env.type, { compilerTransforms, }) - const result = await compiler.compile({ id, code, detectedKinds }) + const result = await compiler.compile({ + id, + code: nextCode, + detectedKinds, + }) - if (!result) { - return code + if (result) { + return { + code: result.code, + map: result.map ?? null, + } } - return { - code: result.code, - map: result.map ?? null, - } + return previousResult ?? nextCode }) }, ) diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 9a950c65e2d..6e61dcaef49 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -25,6 +25,8 @@ import type { CompileStartFrameworkOptions, StartCompilerEnvironment, StartCompilerImportTransform, + StartCompilerPlugin, + StartCompilerTransformResult, } from '../types' type Binding = @@ -41,6 +43,10 @@ type Binding = } type Kind = 'None' | `Root` | `Builder` | LookupKind +type ParsedAst = ReturnType +type StartCompilerAstPlugin = StartCompilerPlugin & { + transformAst: NonNullable +} export type BuiltInLookupKind = | 'ServerFn' @@ -84,11 +90,28 @@ export function isCompilerTransformEnabledForEnv( transform: StartCompilerImportTransform, env: StartCompilerEnvironment, ): boolean { - if (!transform.environment) return true - if (Array.isArray(transform.environment)) { - return transform.environment.includes(env) + return isStartCompilerEnvironmentEnabled(transform.environment, env) +} + +export function isStartCompilerPluginEnabledForEnv( + plugin: StartCompilerPlugin, + env: StartCompilerEnvironment, +): boolean { + return isStartCompilerEnvironmentEnabled(plugin.environment, env) +} + +function isStartCompilerEnvironmentEnabled( + environment: + | StartCompilerEnvironment + | Array + | undefined, + env: StartCompilerEnvironment, +): boolean { + if (!environment) return true + if (Array.isArray(environment)) { + return environment.includes(env) } - return transform.environment === env + return environment === env } const BuiltInLookupSetup: Record< @@ -456,6 +479,7 @@ export class StartCompiler { StartCompilerImportTransform >() private externalLookupSetup = new Map() + private compilerPlugins: Array private externalDirectCallKindsBySource = new Map< string, Map @@ -509,6 +533,7 @@ export class StartCompiler { */ onServerFnsById?: (d: Record) => void compilerTransforms?: Array | undefined + compilerPlugins?: Array | undefined serverFnProviderModuleDirectives?: ReadonlyArray | undefined /** * Returns the currently known server functions from previous builds. @@ -519,6 +544,10 @@ export class StartCompiler { }, ) { this.validLookupKinds = options.lookupKinds + this.compilerPlugins = (options.compilerPlugins ?? []).filter((plugin) => + isStartCompilerPluginEnabledForEnv(plugin, options.env), + ) + for (const transform of options.compilerTransforms ?? []) { const kind = getExternalLookupKind(transform) if (!this.validLookupKinds.has(kind)) continue @@ -881,6 +910,10 @@ export class StartCompiler { const normalizedId = cleanId(id) let hasCachedModule = false + for (const plugin of this.compilerPlugins) { + plugin.invalidateModule?.({ id, envName: this.options.envName }) + } + for (const moduleId of Array.from(this.moduleCache.keys())) { if (cleanId(moduleId) === normalizedId) { this.moduleCache.delete(moduleId) @@ -1000,408 +1033,443 @@ export class StartCompiler { ? new Set([...detectedKinds].filter((k) => this.validLookupKinds.has(k))) : this.validLookupKinds - // Early exit if no kinds to process - if (fileKinds.size === 0) { - return null - } + const astTransformPlugins = this.getAstTransformPluginsForCode(code) + let ast: ParsedAst | undefined + let astHasChanges = false - const hasExternalKinds = hasExternalLookupKinds(fileKinds) - const checkDirectCalls = - hasBuiltInDirectCallKinds(fileKinds) || - (fileKinds.has('ServerFn') && - !hasExternalKinds && - hasBuiltInDirectCallKinds(this.validLookupKinds)) - // Optimization: ServerFn is always a top-level declaration (must be assigned to a variable). - // If the file only has ServerFn, we can skip full AST traversal and only visit - // the specific top-level declarations that have candidates. - const canUseFastPath = areAllKindsTopLevelOnly(fileKinds) - - // Always parse and extract module info upfront. - // This ensures the module is cached for import resolution even if no candidates are found. - const { ast } = this.ingestModule({ code, id, parserFilename }) - - // Single-pass traversal to: - // 1. Collect candidate paths (only candidates, not all CallExpressions) - // 2. Build a map for looking up paths of nested calls in method chains - const candidatePaths: Array = [] - // Map for nested chain lookup - only populated for CallExpressions that are - // part of a method chain (callee.object is a CallExpression) - const chainCallPaths = new Map< - t.CallExpression, - babel.NodePath - >() - - // JSX candidates (e.g., ) - const jsxCandidatePaths: Array> = [] - const checkJSX = needsJSXDetection(fileKinds, this.externalLookupSetup) - // Get module info that was just cached by ingestModule - const moduleInfo = this.moduleCache.get(id)! - const externalDirectCallCandidates = this.getExternalDirectCallCandidates( - fileKinds, - moduleInfo, - ) - const checkExternalDirectCalls = hasExternalDirectCallCandidates( - externalDirectCallCandidates, - ) + builtInTransforms: { + // Early exit if no built-in or import transforms need this file. + if (fileKinds.size === 0) { + break builtInTransforms + } - if (canUseFastPath) { - // Fast path: only visit top-level statements that have potential candidates + const hasExternalKinds = hasExternalLookupKinds(fileKinds) + const checkDirectCalls = + hasBuiltInDirectCallKinds(fileKinds) || + (fileKinds.has('ServerFn') && + !hasExternalKinds && + hasBuiltInDirectCallKinds(this.validLookupKinds)) + // Optimization: ServerFn is always a top-level declaration (must be assigned to a variable). + // If the file only has ServerFn, we can skip full AST traversal and only visit + // the specific top-level declarations that have candidates. + const canUseFastPath = areAllKindsTopLevelOnly(fileKinds) + + // Always parse and extract module info upfront. + // This ensures the module is cached for import resolution even if no candidates are found. + ast = this.ingestModule({ code, id, parserFilename }).ast + + // Single-pass traversal to: + // 1. Collect candidate paths (only candidates, not all CallExpressions) + // 2. Build a map for looking up paths of nested calls in method chains + const candidatePaths: Array = [] + // Map for nested chain lookup - only populated for CallExpressions that are + // part of a method chain (callee.object is a CallExpression) + const chainCallPaths = new Map< + t.CallExpression, + babel.NodePath + >() + + // JSX candidates (e.g., ) + const jsxCandidatePaths: Array> = [] + const checkJSX = needsJSXDetection(fileKinds, this.externalLookupSetup) + // Get module info that was just cached by ingestModule + const moduleInfo = this.moduleCache.get(id)! + const externalDirectCallCandidates = this.getExternalDirectCallCandidates( + fileKinds, + moduleInfo, + ) + const checkExternalDirectCalls = hasExternalDirectCallCandidates( + externalDirectCallCandidates, + ) - // Collect indices of top-level statements that contain candidates - const candidateIndices: Array = [] - for (let i = 0; i < ast.program.body.length; i++) { - const node = ast.program.body[i]! - let declarations: Array | undefined + if (canUseFastPath) { + // Fast path: only visit top-level statements that have potential candidates - if (t.isVariableDeclaration(node)) { - declarations = node.declarations - } else if (t.isExportNamedDeclaration(node) && node.declaration) { - if (t.isVariableDeclaration(node.declaration)) { - declarations = node.declaration.declarations - } - } + // Collect indices of top-level statements that contain candidates + const candidateIndices: Array = [] + for (let i = 0; i < ast.program.body.length; i++) { + const node = ast.program.body[i]! + let declarations: Array | undefined - if (declarations) { - for (const decl of declarations) { - if (decl.init && t.isCallExpression(decl.init)) { - if ( - isMethodChainCandidate(decl.init, fileKinds) || - (checkDirectCalls && - isTopLevelDirectCallCandidateNode(decl.init)) - ) { - candidateIndices.push(i) - break // Only need to mark this statement once - } + if (t.isVariableDeclaration(node)) { + declarations = node.declarations + } else if (t.isExportNamedDeclaration(node) && node.declaration) { + if (t.isVariableDeclaration(node.declaration)) { + declarations = node.declaration.declarations } } - } - } - // Early exit: no potential candidates found at top level - if (candidateIndices.length === 0) { - return null - } - - // Targeted traversal: only visit the specific statements that have candidates - // This is much faster than traversing the entire AST - babel.traverse(ast, { - Program(programPath) { - const bodyPaths = programPath.get('body') - for (const idx of candidateIndices) { - const stmtPath = bodyPaths[idx] - if (!stmtPath) continue - - // Traverse only this statement's subtree - stmtPath.traverse({ - CallExpression(path) { - const node = path.node - const parent = path.parent - - // Check if this call is part of a larger chain (inner call) + if (declarations) { + for (const decl of declarations) { + if (decl.init && t.isCallExpression(decl.init)) { if ( - t.isMemberExpression(parent) && - t.isCallExpression(path.parentPath.parent) + isMethodChainCandidate(decl.init, fileKinds) || + (checkDirectCalls && + isTopLevelDirectCallCandidateNode(decl.init)) ) { - chainCallPaths.set(node, path) - return + candidateIndices.push(i) + break // Only need to mark this statement once } + } + } + } + } - // Method chain pattern - if (isMethodChainCandidate(node, fileKinds)) { - candidatePaths.push({ path }) - return - } + // Early exit: no potential candidates found at top level + if (candidateIndices.length === 0) { + break builtInTransforms + } - if (checkExternalDirectCalls) { - const kind = getExternalDirectCallCandidateKind( - path, - externalDirectCallCandidates, - ) - if (kind) { - candidatePaths.push({ path, kind }) + // Targeted traversal: only visit the specific statements that have candidates + // This is much faster than traversing the entire AST + babel.traverse(ast, { + Program(programPath) { + const bodyPaths = programPath.get('body') + for (const idx of candidateIndices) { + const stmtPath = bodyPaths[idx] + if (!stmtPath) continue + + // Traverse only this statement's subtree + stmtPath.traverse({ + CallExpression(path) { + const node = path.node + const parent = path.parent + + // Check if this call is part of a larger chain (inner call) + if ( + t.isMemberExpression(parent) && + t.isCallExpression(path.parentPath.parent) + ) { + chainCallPaths.set(node, path) return } - } - if (isTopLevelDirectCallCandidate(path)) { - candidatePaths.push({ path }) - } - }, - }) - } - // Stop traversal after processing Program - programPath.stop() - }, - }) - } else { - // Normal path: full traversal for non-fast-path kinds - babel.traverse(ast, { - CallExpression: (path) => { - const node = path.node - const parent = path.parent - - // Check if this call is part of a larger chain (inner call) - // If so, store it for method chain lookup but don't treat as candidate - if ( - t.isMemberExpression(parent) && - t.isCallExpression(path.parentPath.parent) - ) { - // This is an inner call in a chain - store for later lookup - chainCallPaths.set(node, path) - return - } + // Method chain pattern + if (isMethodChainCandidate(node, fileKinds)) { + candidatePaths.push({ path }) + return + } - // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.) - if (isMethodChainCandidate(node, fileKinds)) { - candidatePaths.push({ path }) - return - } + if (checkExternalDirectCalls) { + const kind = getExternalDirectCallCandidateKind( + path, + externalDirectCallCandidates, + ) + if (kind) { + candidatePaths.push({ path, kind }) + return + } + } - // External direct-call transforms are import-bound. Direct imports - // already identify the transform kind, so skip async import tracing. - if (checkExternalDirectCalls) { - const kind = getExternalDirectCallCandidateKind( - path, - externalDirectCallCandidates, - ) - if (kind) { - candidatePaths.push({ path, kind }) + if (isTopLevelDirectCallCandidate(path)) { + candidatePaths.push({ path }) + } + }, + }) + } + // Stop traversal after processing Program + programPath.stop() + }, + }) + } else { + // Normal path: full traversal for non-fast-path kinds + babel.traverse(ast, { + CallExpression: (path) => { + const node = path.node + const parent = path.parent + + // Check if this call is part of a larger chain (inner call) + // If so, store it for method chain lookup but don't treat as candidate + if ( + t.isMemberExpression(parent) && + t.isCallExpression(path.parentPath.parent) + ) { + // This is an inner call in a chain - store for later lookup + chainCallPaths.set(node, path) return } - } - if (checkDirectCalls && isTopLevelDirectCallCandidate(path)) { - candidatePaths.push({ path }) - return - } + // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.) + if (isMethodChainCandidate(node, fileKinds)) { + candidatePaths.push({ path }) + return + } - // Pattern 2: Direct call pattern - if (checkDirectCalls) { - if ( - isNestedDirectCallCandidate( - node, - fileKinds, - this.externalLookupSetup, + // External direct-call transforms are import-bound. Direct imports + // already identify the transform kind, so skip async import tracing. + if (checkExternalDirectCalls) { + const kind = getExternalDirectCallCandidateKind( + path, + externalDirectCallCandidates, ) - ) { + if (kind) { + candidatePaths.push({ path, kind }) + return + } + } + + if (checkDirectCalls && isTopLevelDirectCallCandidate(path)) { candidatePaths.push({ path }) return } - } - }, - // Pattern 3: JSX element pattern (e.g., ) - // Collect JSX elements where the component is imported from a known package - // and resolves to a JSX kind (e.g., ClientOnly from @tanstack/react-router) - JSXElement: (path) => { - if (!checkJSX) return - const openingElement = path.node.openingElement - const nameNode = openingElement.name - - // Only handle simple identifier names (not namespaced or member expressions) - if (!t.isJSXIdentifier(nameNode)) return + // Pattern 2: Direct call pattern + if (checkDirectCalls) { + if ( + isNestedDirectCallCandidate( + node, + fileKinds, + this.externalLookupSetup, + ) + ) { + candidatePaths.push({ path }) + return + } + } + }, + // Pattern 3: JSX element pattern (e.g., ) + // Collect JSX elements where the component is imported from a known package + // and resolves to a JSX kind (e.g., ClientOnly from @tanstack/react-router) + JSXElement: (path) => { + if (!checkJSX) return - const componentName = nameNode.name - const binding = moduleInfo.bindings.get(componentName) + const openingElement = path.node.openingElement + const nameNode = openingElement.name - // Must be an import binding from a known package - if (!binding || binding.type !== 'import') return + // Only handle simple identifier names (not namespaced or member expressions) + if (!t.isJSXIdentifier(nameNode)) return - // Verify the import source is a known TanStack router package - const knownExports = this.knownRootImports.get(binding.source) - if (!knownExports) return + const componentName = nameNode.name + const binding = moduleInfo.bindings.get(componentName) - // Verify the imported name resolves to a JSX kind (e.g., ClientOnlyJSX) - const kind = knownExports.get(binding.importedName) - if (kind !== 'ClientOnlyJSX') return + // Must be an import binding from a known package + if (!binding || binding.type !== 'import') return - jsxCandidatePaths.push(path) - }, - }) - } + // Verify the import source is a known TanStack router package + const knownExports = this.knownRootImports.get(binding.source) + if (!knownExports) return - if (candidatePaths.length === 0 && jsxCandidatePaths.length === 0) { - return null - } + // Verify the imported name resolves to a JSX kind (e.g., ClientOnlyJSX) + const kind = knownExports.get(binding.importedName) + if (kind !== 'ClientOnlyJSX') return - // Resolve only candidates whose import scan did not already prove the kind. - const resolvedCandidates: Array<{ - path: babel.NodePath - kind: Kind - }> = [] - const unresolvedCandidates: Array = [] - - for (const candidate of candidatePaths) { - if (candidate.kind) { - resolvedCandidates.push({ - path: candidate.path, - kind: candidate.kind, + jsxCandidatePaths.push(path) + }, }) - } else { - unresolvedCandidates.push(candidate) } - } - if (unresolvedCandidates.length > 0) { - resolvedCandidates.push( - ...(await Promise.all( - unresolvedCandidates.map(async (candidate) => ({ - path: candidate.path, - kind: await this.resolveExprKind(candidate.path.node, id), - })), - )), - ) - } + if (candidatePaths.length === 0 && jsxCandidatePaths.length === 0) { + break builtInTransforms + } + + // Resolve only candidates whose import scan did not already prove the kind. + const resolvedCandidates: Array<{ + path: babel.NodePath + kind: Kind + }> = [] + const unresolvedCandidates: Array = [] - // Filter to valid candidates - const validCandidates = resolvedCandidates.filter(({ path, kind }) => { - if ( - !this.validLookupKinds.has(kind as Exclude) - ) { - return false + for (const candidate of candidatePaths) { + if (candidate.kind) { + resolvedCandidates.push({ + path: candidate.path, + kind: candidate.kind, + }) + } else { + unresolvedCandidates.push(candidate) + } } - if ( - isLookupKind(kind) && - kind !== 'ClientOnlyJSX' && - !isMethodChainCandidate(path.node, fileKinds) - ) { - return isDirectCallCandidateForKind(kind, this.externalLookupSetup) + if (unresolvedCandidates.length > 0) { + resolvedCandidates.push( + ...(await Promise.all( + unresolvedCandidates.map(async (candidate) => ({ + path: candidate.path, + kind: await this.resolveExprKind(candidate.path.node, id), + })), + )), + ) } - return true - }) as Array<{ - path: babel.NodePath - kind: Exclude - }> + // Filter to valid candidates + const validCandidates = resolvedCandidates.filter(({ path, kind }) => { + if ( + !this.validLookupKinds.has( + kind as Exclude, + ) + ) { + return false + } - if (validCandidates.length === 0 && jsxCandidatePaths.length === 0) { - return null - } + if ( + isLookupKind(kind) && + kind !== 'ClientOnlyJSX' && + !isMethodChainCandidate(path.node, fileKinds) + ) { + return isDirectCallCandidateForKind(kind, this.externalLookupSetup) + } - // Process valid candidates to collect method chains - const pathsToRewrite: Array<{ - path: babel.NodePath - kind: Exclude - methodChain: MethodChainPaths - }> = [] - - for (const { path, kind } of validCandidates) { - const node = path.node - - // Collect method chain paths by walking DOWN from root through the chain - const methodChain: MethodChainPaths = { - middleware: null, - inputValidator: null, - handler: null, - server: null, - client: null, - } + return true + }) as Array<{ + path: babel.NodePath + kind: Exclude + }> - // Walk down the call chain using nodes, look up paths from map - let currentNode: t.CallExpression = node - let currentPath: babel.NodePath = path + if (validCandidates.length === 0 && jsxCandidatePaths.length === 0) { + break builtInTransforms + } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const callee = currentNode.callee - if (!t.isMemberExpression(callee)) { - break + // Process valid candidates to collect method chains + const pathsToRewrite: Array<{ + path: babel.NodePath + kind: Exclude + methodChain: MethodChainPaths + }> = [] + + for (const { path, kind } of validCandidates) { + const node = path.node + + // Collect method chain paths by walking DOWN from root through the chain + const methodChain: MethodChainPaths = { + middleware: null, + inputValidator: null, + handler: null, + server: null, + client: null, } - // Record method chain path if it's a known method - if (t.isIdentifier(callee.property)) { - const name = callee.property.name as keyof MethodChainPaths - if (name in methodChain) { - // Get first argument path - const args = currentPath.get('arguments') - const firstArgPath = - Array.isArray(args) && args.length > 0 ? (args[0] ?? null) : null - methodChain[name] = { - callPath: currentPath, - firstArgPath, + // Walk down the call chain using nodes, look up paths from map + let currentNode: t.CallExpression = node + let currentPath: babel.NodePath = path + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const callee = currentNode.callee + if (!t.isMemberExpression(callee)) { + break + } + + // Record method chain path if it's a known method + if (t.isIdentifier(callee.property)) { + const name = callee.property.name as keyof MethodChainPaths + if (name in methodChain) { + // Get first argument path + const args = currentPath.get('arguments') + const firstArgPath = + Array.isArray(args) && args.length > 0 + ? (args[0] ?? null) + : null + methodChain[name] = { + callPath: currentPath, + firstArgPath, + } } } - } - // Move to the inner call (the object of the member expression) - if (!t.isCallExpression(callee.object)) { - break - } - currentNode = callee.object - // Look up path from chain map, or use candidate path if not found - const nextPath = chainCallPaths.get(currentNode) - if (!nextPath) { - break + // Move to the inner call (the object of the member expression) + if (!t.isCallExpression(callee.object)) { + break + } + currentNode = callee.object + // Look up path from chain map, or use candidate path if not found + const nextPath = chainCallPaths.get(currentNode) + if (!nextPath) { + break + } + currentPath = nextPath } - currentPath = nextPath + + pathsToRewrite.push({ path, kind, methodChain }) } - pathsToRewrite.push({ path, kind, methodChain }) - } + const refIdents = findReferencedIdentifiers(ast) - const refIdents = findReferencedIdentifiers(ast) + const context: CompilationContext = { + ast, + id, + code, + env: this.options.env, + envName: this.options.envName, + mode: this.mode, + root: this.options.root, + framework: this.options.framework, + providerEnvName: this.options.providerEnvName, + types: t, + parseExpression: (expressionCode) => + babel.template.expression(expressionCode, { + placeholderPattern: false, + })() as t.Expression, + + generateFunctionId: (opts) => this.generateFunctionId(opts), + getKnownServerFns: this.options.getKnownServerFns, + serverFnProviderModuleDirectives: + this.options.serverFnProviderModuleDirectives, + onServerFnsById: this.options.onServerFnsById, + } - const context: CompilationContext = { - ast, - id, - code, - env: this.options.env, - envName: this.options.envName, - mode: this.mode, - root: this.options.root, - framework: this.options.framework, - providerEnvName: this.options.providerEnvName, - types: t, - parseExpression: (expressionCode) => - babel.template.expression(expressionCode, { - placeholderPattern: false, - })() as t.Expression, - - generateFunctionId: (opts) => this.generateFunctionId(opts), - getKnownServerFns: this.options.getKnownServerFns, - serverFnProviderModuleDirectives: - this.options.serverFnProviderModuleDirectives, - onServerFnsById: this.options.onServerFnsById, - } + // Group candidates by kind for batch processing + const candidatesByKind = new Map< + Exclude, + Array + >() + + for (const { path: candidatePath, kind, methodChain } of pathsToRewrite) { + const candidate: RewriteCandidate = { path: candidatePath, methodChain } + const existing = candidatesByKind.get(kind) + if (existing) { + existing.push(candidate) + } else { + candidatesByKind.set(kind, [candidate]) + } + } - // Group candidates by kind for batch processing - const candidatesByKind = new Map< - Exclude, - Array - >() + // External transforms run before built-ins by default so they can augment + // user handlers before server function extraction clones provider bodies. + this.runExternalTransforms('pre', candidatesByKind, context) - for (const { path: candidatePath, kind, methodChain } of pathsToRewrite) { - const candidate: RewriteCandidate = { path: candidatePath, methodChain } - const existing = candidatesByKind.get(kind) - if (existing) { - existing.push(candidate) - } else { - candidatesByKind.set(kind, [candidate]) + for (const kind of BuiltInKindHandlerOrder) { + const candidates = candidatesByKind.get(kind) + if (!candidates) continue + const handler = BuiltInKindHandlers[kind] + handler(candidates, context, kind) } - } - // External transforms run before built-ins by default so they can augment - // user handlers before server function extraction clones provider bodies. - this.runExternalTransforms('pre', candidatesByKind, context) + this.runExternalTransforms('post', candidatesByKind, context) - for (const kind of BuiltInKindHandlerOrder) { - const candidates = candidatesByKind.get(kind) - if (!candidates) continue - const handler = BuiltInKindHandlers[kind] - handler(candidates, context, kind) + // Handle JSX candidates (e.g., ) + // Validation was already done during traversal - just call the handler + for (const jsxPath of jsxCandidatePaths) { + handleClientOnlyJSX(jsxPath, { env: 'server' }) + } + + deadCodeElimination(ast, refIdents) + astHasChanges = true } - this.runExternalTransforms('post', candidatesByKind, context) + if (!ast && astTransformPlugins.length > 0) { + ast = parseAst({ code, filename: parserFilename ?? cleanId(id) }) + } - // Handle JSX candidates (e.g., ) - // Validation was already done during traversal - just call the handler - for (const jsxPath of jsxCandidatePaths) { - handleClientOnlyJSX(jsxPath, { env: 'server' }) + if (ast && astTransformPlugins.length > 0) { + astHasChanges = + this.runAstTransforms({ + ast, + code, + id, + transforms: astTransformPlugins, + }) || astHasChanges } - deadCodeElimination(ast, refIdents) + return ast && astHasChanges + ? this.generateResultFromAst(ast, code, id) + : null + } + private generateResultFromAst( + ast: ParsedAst, + sourceCode: string, + id: string, + ): StartCompilerTransformResult { const result = generateFromAst(ast, { sourceMaps: true, sourceFileName: id, @@ -1409,17 +1477,66 @@ export class StartCompiler { }) // @babel/generator does not populate sourcesContent because it only has - // the AST, not the original text. Without this, Vite's composed - // sourcemap omits the original source, causing downstream consumers - // (e.g. import-protection snippet display) to fall back to the shorter - // compiled output and fail to resolve original line numbers. + // the AST, not the original text. Without this, Vite's composed sourcemap + // omits the original source, causing downstream consumers to fall back to + // the compiled output and fail to resolve original line numbers. if (result.map) { - result.map.sourcesContent = [code] + result.map.sourcesContent = [sourceCode] } return result } + private getAstTransformPluginsForCode( + code: string, + ): Array { + return this.compilerPlugins.filter( + (plugin): plugin is StartCompilerAstPlugin => { + if (!plugin.transformAst) return false + if (!plugin.detect) return true + plugin.detect.lastIndex = 0 + return plugin.detect.test(code) + }, + ) + } + + private runAstTransforms({ + ast, + code, + id, + transforms, + }: { + ast: ParsedAst + code: string + id: string + transforms: Array + }): boolean { + let modified = false + + for (const plugin of transforms) { + const context = { + ast, + code, + id, + env: this.options.env, + envName: this.options.envName, + mode: this.mode, + root: this.options.root, + framework: this.options.framework, + providerEnvName: this.options.providerEnvName, + types: t, + parseExpression: (expressionCode: string) => + babel.template.expression(expressionCode, { + placeholderPattern: false, + })() as t.Expression, + } + + modified = plugin.transformAst(context) || modified + } + + return modified + } + private runExternalTransforms( order: 'pre' | 'post', candidatesByKind: Map< diff --git a/packages/start-plugin-core/src/start-compiler/config.ts b/packages/start-plugin-core/src/start-compiler/config.ts index e9d7d95e5e8..387343bad9c 100644 --- a/packages/start-plugin-core/src/start-compiler/config.ts +++ b/packages/start-plugin-core/src/start-compiler/config.ts @@ -3,17 +3,20 @@ import { getExternalLookupKind, getLookupKindsForEnv, isCompilerTransformEnabledForEnv, + isStartCompilerPluginEnabledForEnv, } from './compiler' import type { BuiltInLookupKind, LookupConfig } from './compiler' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, + StartCompilerPlugin, } from '../types' export function getTransformCodeFilterForEnv( env: 'client' | 'server', opts?: { compilerTransforms?: Array | undefined + compilerPlugins?: Array | undefined }, ): Array { const validKinds = getLookupKindsForEnv(env, opts) @@ -33,6 +36,12 @@ export function getTransformCodeFilterForEnv( } } + for (const plugin of opts?.compilerPlugins ?? []) { + if (plugin.detect && isStartCompilerPluginEnabledForEnv(plugin, env)) { + patterns.push(plugin.detect) + } + } + return patterns } diff --git a/packages/start-plugin-core/src/start-compiler/host.ts b/packages/start-plugin-core/src/start-compiler/host.ts index e1da89d7f28..c225347deac 100644 --- a/packages/start-plugin-core/src/start-compiler/host.ts +++ b/packages/start-plugin-core/src/start-compiler/host.ts @@ -3,6 +3,9 @@ import { getLookupConfigurationsForEnv } from './config' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, + StartCompilerPlugin, + StartCompilerTransformResult, + StartCompilerVirtualModuleContext, } from '../types' import type { DevServerFnModuleSpecifierEncoder, @@ -19,6 +22,7 @@ export interface CreateStartCompilerOptions { mode: 'dev' | 'build' generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined + compilerPlugins?: Array | undefined serverFnProviderModuleDirectives?: ReadonlyArray | undefined onServerFnsById?: (d: Record) => void getKnownServerFns: () => Record @@ -48,6 +52,7 @@ export function createStartCompiler( generateFunctionId: options.generateFunctionId, onServerFnsById: options.onServerFnsById, compilerTransforms: options.compilerTransforms, + compilerPlugins: options.compilerPlugins, serverFnProviderModuleDirectives: options.serverFnProviderModuleDirectives, getKnownServerFns: options.getKnownServerFns, devServerFnModuleSpecifierEncoder: options.encodeModuleSpecifierInDev, @@ -81,6 +86,7 @@ export function matchesCodeFilters( filters: ReadonlyArray, ): boolean { for (const pattern of filters) { + pattern.lastIndex = 0 if (pattern.test(code)) { return true } @@ -88,3 +94,43 @@ export function matchesCodeFilters( return false } + +export function createCompilerVirtualModuleIdPattern( + compilerPlugins: ReadonlyArray, +) { + const patterns = compilerPlugins + .map((plugin) => plugin.virtualModuleIdPattern) + .filter((pattern): pattern is RegExp => !!pattern) + + if (patterns.length === 0) { + return undefined + } + + return new RegExp( + patterns.map((pattern) => `(?:${pattern.source})`).join('|'), + ) +} + +export function loadCompilerVirtualModule( + compilerPlugins: ReadonlyArray, + context: StartCompilerVirtualModuleContext, +): StartCompilerTransformResult | null { + for (const compilerPlugin of compilerPlugins) { + const pattern = compilerPlugin.virtualModuleIdPattern + if (!pattern) { + continue + } + + pattern.lastIndex = 0 + if (!pattern.test(context.id)) { + continue + } + + const result = compilerPlugin.loadVirtualModule?.(context) + if (result) { + return result + } + } + + return null +} diff --git a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts index 1c52d124eb3..dd9617f3c5a 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -462,6 +462,13 @@ export function buildRouteManifestRoutes(options: { getChunkCssAssets, getChunkPreloads: options.assetResolvers.getChunkPreloads, }) + + mergeReachableHydrationChunkData({ + route: targetRoute, + chunk, + chunksByFileName: options.chunksByFileName, + getChunkCssAssets, + }) } } @@ -472,6 +479,12 @@ export function buildRouteManifestRoutes(options: { getChunkCssAssets, getChunkPreloads: options.assetResolvers.getChunkPreloads, }) + mergeReachableHydrationChunkData({ + route: rootRoute, + chunk: options.entryChunk, + chunksByFileName: options.chunksByFileName, + getChunkCssAssets, + }) if (options.additionalRouteAssets) { for (const [routeId, assets] of Object.entries( @@ -495,6 +508,54 @@ export function buildRouteManifestRoutes(options: { return routes } +function mergeReachableHydrationChunkData(options: { + route: RouteTreeRoute + chunk: NormalizedClientChunk + chunksByFileName: ReadonlyMap + getChunkCssAssets: (chunk: NormalizedClientChunk) => Array +}) { + const visitedStaticChunks = new Set() + const mergedHydrationChunks = new Set() + + const mergeHydrationChunk = (chunk: NormalizedClientChunk) => { + if (mergedHydrationChunks.has(chunk.fileName)) return + mergedHydrationChunks.add(chunk.fileName) + + options.route.assets = appendUniqueAssets( + options.route.assets, + options.getChunkCssAssets(chunk), + ) + + for (const dynamicImport of chunk.dynamicImports) { + const dynamicChunk = options.chunksByFileName.get(dynamicImport) + if (dynamicChunk?.hydrationIds.length) { + mergeHydrationChunk(dynamicChunk) + } + } + } + + const visitStaticChunk = (chunk: NormalizedClientChunk) => { + if (visitedStaticChunks.has(chunk.fileName)) return + visitedStaticChunks.add(chunk.fileName) + + for (const importedFileName of chunk.imports) { + const importedChunk = options.chunksByFileName.get(importedFileName) + if (importedChunk) { + visitStaticChunk(importedChunk) + } + } + + for (const dynamicImport of chunk.dynamicImports) { + const dynamicChunk = options.chunksByFileName.get(dynamicImport) + if (dynamicChunk?.hydrationIds.length) { + mergeHydrationChunk(dynamicChunk) + } + } + } + + visitStaticChunk(options.chunk) +} + export { getRouteFilePathsFromModuleIds, normalizeViteClientBuild, diff --git a/packages/start-plugin-core/src/types.ts b/packages/start-plugin-core/src/types.ts index bc76726875d..f0acdbfe35c 100644 --- a/packages/start-plugin-core/src/types.ts +++ b/packages/start-plugin-core/src/types.ts @@ -1,5 +1,6 @@ import type * as babel from '@babel/core' import type * as t from '@babel/types' +import type { GeneratorResult } from '@tanstack/router-utils' import type { TanStackStartOutputConfig } from './schema' export type CompileStartFrameworkOptions = 'react' | 'solid' | 'vue' @@ -62,6 +63,36 @@ export interface StartCompilerImportTransform { ) => void } +export interface StartCompilerTransformResult { + code: string + map?: GeneratorResult['map'] | null +} + +export interface StartCompilerVirtualModuleContext { + readonly id: string + readonly root: string + readonly env: StartCompilerEnvironment + readonly envName: string + readonly code?: string +} + +export interface StartCompilerPlugin { + name: string + environment?: + | StartCompilerEnvironment + | Array + | undefined + detect?: RegExp | undefined + virtualModuleIdPattern?: RegExp | undefined + transformAst?: ( + context: StartCompilerTransformContext, + ) => boolean | null | undefined + loadVirtualModule?: ( + context: StartCompilerVirtualModuleContext, + ) => StartCompilerTransformResult | null + invalidateModule?: (context: { id: string; envName: string }) => void +} + export interface NormalizedBasePaths { publicBase: string assetBase: { @@ -82,6 +113,7 @@ export interface NormalizedClientChunk { dynamicImports: Array css: Array routeFilePaths: Array + hydrationIds: Array } export interface NormalizedClientBuild { diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index 3130ca47c15..9b11b58c823 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -8,12 +8,15 @@ import { import { detectKindsInCode } from '../../start-compiler/compiler' import { getTransformCodeFilterForEnv } from '../../start-compiler/config' import { + createCompilerVirtualModuleIdPattern, createStartCompiler, + loadCompilerVirtualModule, mergeServerFnsById, } from '../../start-compiler/host' import { generateServerFnResolverModule } from '../../start-compiler/server-fn-resolver-module' import { cleanId } from '../../start-compiler/utils' import { createVirtualModule } from '../createVirtualModule' +import { createHydrateCompilerPlugin } from '../../hydrate-when-transform' import { resolveViteId } from '../../utils' import { createViteDevServerFnModuleSpecifierEncoder, @@ -23,6 +26,7 @@ import { mergeHotUpdateModules } from './hot-update' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, + StartCompilerPlugin, } from '../../types' import type { GenerateFunctionIdFnOptional, @@ -104,6 +108,25 @@ function invalidateServerFnLookupModules( ) } +function invalidateCompilerVirtualModules( + environment: ModuleInvalidationEnvironment, + ids: Iterable, + pattern: RegExp | undefined, +) { + if (!pattern) { + return [] + } + + return invalidateMatchingFileModules(environment, ids, (fileModule) => { + if (!fileModule.id) { + return false + } + + pattern.lastIndex = 0 + return pattern.test(fileModule.id) + }) +} + function getServerFnProviderIds(ids: Iterable) { const providerIds = new Set() @@ -170,6 +193,7 @@ export interface StartCompilerPluginOptions { */ generateFunctionId?: GenerateFunctionIdFnOptional compilerTransforms?: Array | undefined + compilerPlugins?: Array | undefined serverFnProviderModuleDirectives?: ReadonlyArray | undefined /** * The Vite environment name for the server function provider. @@ -181,6 +205,15 @@ export function startCompilerPlugin( opts: StartCompilerPluginOptions, ): PluginOption { const compilers = new Map>() + const compilerPlugins = [ + createHydrateCompilerPlugin(), + ...(opts.compilerPlugins ?? []), + ] + const compilerVirtualModuleIdPattern = + createCompilerVirtualModuleIdPattern(compilerPlugins) + const environmentByName = new Map( + opts.environments.map((environment) => [environment.name, environment]), + ) // Shared registry of server functions across all environments const serverFnsById: Record = {} @@ -218,6 +251,7 @@ export function startCompilerPlugin( // Derive transform code filter from KindDetectionPatterns (single source of truth) const transformCodeFilter = getTransformCodeFilterForEnv(environment.type, { compilerTransforms, + compilerPlugins, }) return { name: `tanstack-start-core::server-fn:${environment.name}`, @@ -254,6 +288,7 @@ export function startCompilerPlugin( providerEnvName: opts.providerEnvName, generateFunctionId: opts.generateFunctionId, compilerTransforms, + compilerPlugins, serverFnProviderModuleDirectives, onServerFnsById, getKnownServerFns: () => serverFnsById, @@ -307,6 +342,7 @@ export function startCompilerPlugin( code, detectedKinds, }) + return result }, }, @@ -370,9 +406,14 @@ export function startCompilerPlugin( invalidateModuleNodes(this.environment, importerModulesToInvalidate) invalidateServerFnLookupModules(this.environment, idsToInvalidate) + const compilerVirtualModules = invalidateCompilerVirtualModules( + this.environment, + idsToInvalidate, + compilerVirtualModuleIdPattern, + ) if (environment.type !== 'server') { - return + return mergeHotUpdateModules(ctx.modules, compilerVirtualModules) } invalidateModuleNodes(this.environment, ctx.modules) @@ -388,7 +429,10 @@ export function startCompilerPlugin( [...idsToInvalidate, ...providerIdsToInvalidate], ) - return mergeHotUpdateModules(ctx.modules, providerModules) + return mergeHotUpdateModules(ctx.modules, [ + ...compilerVirtualModules, + ...providerModules, + ]) } return finishHotUpdate() @@ -415,6 +459,28 @@ export function startCompilerPlugin( }, }, }, + { + name: 'tanstack-start-core:compiler-virtual-module', + enforce: 'pre', + load: { + filter: { + id: compilerVirtualModuleIdPattern ?? /$^/, + }, + handler(id) { + const environment = environmentByName.get(this.environment.name) + if (!environment || !compilerVirtualModuleIdPattern) { + return null + } + + return loadCompilerVirtualModule(compilerPlugins, { + id, + root, + env: environment.type, + envName: this.environment.name, + }) + }, + }, + }, // Validate server function ID in dev mode { name: 'tanstack-start-core:validate-server-fn-id', @@ -443,7 +509,7 @@ export function startCompilerPlugin( typeof decoded.file === 'string' && typeof decoded.export === 'string' ) { - // Use the Vite strategy to decode the module specifier + // Use the Vite when to decode the module specifier // back to the original source file path. const sourceFile = decodeViteDevServerModuleSpecifier( decoded.file, diff --git a/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts b/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts index 8ac7915ff0c..c14609bc6a4 100644 --- a/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts +++ b/packages/start-plugin-core/src/vite/start-manifest-plugin/normalized-client-build.ts @@ -1,4 +1,5 @@ import { tsrSplit } from '@tanstack/router-plugin' +import { tssHydrate } from '../../hydration-constants' import { getCssAssetSource } from '../../start-manifest-plugin/inlineCss' import type { Rollup } from 'vite' import type { NormalizedClientBuild, NormalizedClientChunk } from '../../types' @@ -13,6 +14,7 @@ export function normalizeViteClientChunk( dynamicImports: chunk.dynamicImports, css: Array.from(chunk.viteMetadata?.importedCss ?? []), routeFilePaths: getRouteFilePathsFromModuleIds(chunk.moduleIds), + hydrationIds: getHydrationIdsFromModuleIds(chunk.moduleIds), } } @@ -147,3 +149,38 @@ export function getRouteFilePathsFromModuleIds(moduleIds: Array) { return routeFilePaths ?? [] } + +export function getHydrationIdsFromModuleIds(moduleIds: Array) { + let hydrationIds: Array | undefined + let seen: Set | undefined + + for (const moduleId of moduleIds) { + const queryIndex = moduleId.indexOf('?') + + if (queryIndex < 0) { + continue + } + + const query = moduleId.slice(queryIndex + 1) + + if (!query.includes(tssHydrate)) { + continue + } + + const hydrationId = new URLSearchParams(query).get(tssHydrate) + + if (!hydrationId || seen?.has(hydrationId)) { + continue + } + + if (hydrationIds === undefined || seen === undefined) { + hydrationIds = [] + seen = new Set() + } + + hydrationIds.push(hydrationId) + seen.add(hydrationId) + } + + return hydrationIds ?? [] +} diff --git a/packages/start-plugin-core/tests/hydrate-when-transform.test.ts b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts new file mode 100644 index 00000000000..597d1ff3d22 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts @@ -0,0 +1,607 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as t from '@babel/types' +import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { describe, expect, test } from 'vitest' +import { createHydrateCompilerPlugin } from '../src/hydrate-when-transform' + +const root = '/repo' +const id = '/repo/src/routes/about.tsx' + +type HydrateBoundary = { + id: string + exportName: string + index: number +} + +function getHydrateBoundariesFromCode(code: string): Array { + const boundaries: Array = [] + const hydrateImportPattern = + /import\("([^"]*[?&]tss-hydrate=[^"]*)"\),\s*"([^"]+)"/g + let match: RegExpExecArray | null + + while ((match = hydrateImportPattern.exec(code))) { + const importId = match[1]! + const queryIndex = importId.indexOf('?') + const params = new URLSearchParams(importId.slice(queryIndex + 1)) + const boundaryId = params.get('tss-hydrate') + const rawIndex = params.get('tss-hydrate-index') + const index = rawIndex === null ? Number.NaN : Number(rawIndex) + + if (boundaryId && Number.isInteger(index)) { + boundaries.push({ + id: boundaryId, + exportName: match[2]!, + index, + }) + } + } + + return boundaries.sort((a, b) => a.index - b.index) +} + +function virtualHydrateId( + file: string, + boundary: Pick, +) { + const params = new URLSearchParams() + params.set('tss-hydrate', boundary.id) + params.set('tss-hydrate-index', String(boundary.index)) + return `${file}?${params.toString()}` +} + +function compileHydrate(options: { + code: string + id: string + root: string + env: 'client' | 'server' + envName?: string + plugin?: ReturnType +}) { + const plugin = options.plugin ?? createHydrateCompilerPlugin() + const envName = options.envName ?? options.env + const ast = parseAst({ code: options.code, sourceFilename: options.id }) + const result = plugin.transformAst?.({ + ast, + code: options.code, + id: options.id, + root: options.root, + env: options.env, + envName, + mode: 'dev', + framework: 'react', + providerEnvName: 'ssr', + types: t, + parseExpression: (expressionCode) => t.identifier(expressionCode), + }) + if (!result) return null + + const generated = generateFromAst(ast, { + sourceMaps: true, + sourceFileName: options.id, + filename: options.id, + }) + + return { + code: generated.code, + map: generated.map, + boundaries: getHydrateBoundariesFromCode(generated.code), + plugin, + } +} + +function loadVirtualHydrateModule(options: { + code: string + id: string + root: string + envName?: string +}) { + const plugin = createHydrateCompilerPlugin() + return plugin.loadVirtualModule?.({ + code: options.code, + id: options.id, + root: options.root, + env: 'client', + envName: options.envName ?? 'client', + }) +} + +describe('Hydrate compiler transform', () => { + test('splits Hydrate children behind a lazy import', () => { + const result = compileHydrate({ + code: ` + import { Hydrate } from '@tanstack/react-start' + import { visible } from '@tanstack/react-start/hydration' + + export function Page() { + return ( + + + + ) + } + + function Widget(props: { title: string }) { + return

{props.title}

+ } + `, + id, + root, + env: 'client', + }) + + expect(result?.code).toContain('lazyHydratedComponent') + expect(result?.code).toContain('tss-hydrate=') + expect(result?.code).not.toContain('tss-hydrate-preload') + expect(result?.code).toContain('splitId=') + expect(result?.code).toContain('preload=') + }) + + test('uses the Solid Start import source for Solid Hydrate boundaries', () => { + const result = compileHydrate({ + code: ` + import { Hydrate } from '@tanstack/solid-start' + import { visible } from '@tanstack/solid-start/hydration' + + export function Page() { + return ( + + + + ) + } + + function Widget(props: { title: string }) { + return

{props.title}

+ } + `, + id, + root, + env: 'client', + }) + + expect(result?.code).toContain( + 'lazyHydratedComponent } from "@tanstack/solid-start"', + ) + expect(result?.code).toContain('tss-hydrate=') + expect(result?.code).toContain('splitId=') + expect(result?.code).toContain('preload=') + }) + + test('rejects function-as-children unless the boundary opts out of splitting', () => { + expect(() => + compileHydrate({ + code: ` + import { Hydrate } from '@tanstack/react-start' + import { idle } from '@tanstack/react-start/hydration' + + export function Page() { + return ( + + {() =>

child

} +
+ ) + } + `, + id, + root, + env: 'client', + }), + ).toThrow(/function-as-children/) + }) + + test('rejects hook calls that would be moved into an extracted component', () => { + expect(() => + compileHydrate({ + code: ` + import { Hydrate } from '@tanstack/react-start' + import { idle } from '@tanstack/react-start/hydration' + + function useThing() { + return 'thing' + } + + export function Page() { + return ( + +

{useThing()}

+
+ ) + } + `, + id, + root, + env: 'client', + }), + ).toThrow(/hooks/) + }) + + test('strips Hydrate fallback from the server transform', () => { + const result = compileHydrate({ + code: ` + import { Hydrate } from '@tanstack/react-start' + import { idle, visible } from '@tanstack/react-start/hydration' + + const spreadProps = { + when: visible(), + fallback:
bound
, + } + + export function Page() { + return ( + <> + fallback
} + > + + + inline} + > + + + spread, + }} + > + + + + + + + ) + } + + function Widget(props: { title: string }) { + return

{props.title}

+ } + `, + id, + root, + env: 'server', + }) + + expect(result?.code).not.toContain('fallback=') + expect(result?.code).not.toContain('fallback:') + expect(result?.code).not.toContain('server-fallback') + expect(result?.code).not.toContain('server-inline') + expect(result?.code).not.toContain('server-spread') + expect(result?.code).not.toContain('server-bound-spread') + expect(result?.code).toContain('splitId=') + expect(result?.code).toContain('') + expect(result?.code).toContain('') + expect(result?.code).toContain('') + expect(result?.code).toContain('') + }) + + test('supports nested Hydrate boundaries in extracted virtual modules', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const code = ` + import { Hydrate } from '@tanstack/react-start' + import { interaction, visible } from '@tanstack/react-start/hydration' + + const unused = 'remove me' + + export function Page() { + return ( + +
+ + + +
+
+ ) + } + ` + const firstPass = compileHydrate({ + code, + id: file, + root: dir, + env: 'client', + }) + expect(firstPass?.boundaries).toHaveLength(1) + + const virtualId = virtualHydrateId(file, firstPass!.boundaries[0]!) + const virtualModule = loadVirtualHydrateModule({ + code, + id: virtualId, + root: dir, + }) + expect(virtualModule?.code).not.toContain('remove me') + + const boundaryIndex = firstPass!.boundaries[0]!.index + const nestedPass = compileHydrate({ + code: virtualModule!.code, + id: virtualId, + root: dir, + env: 'client', + }) + + expect(boundaryIndex).toBe(0) + expect(nestedPass?.code).toContain('Hydrate_1') + expect(nestedPass?.code).toContain('tss-hydrate=') + }) + + test('keeps sibling boundary ids stable after nested boundaries', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const code = ` + import { Hydrate } from '@tanstack/react-start' + import { idle, interaction, visible } from '@tanstack/react-start/hydration' + + export function Page() { + return ( + <> + +
+ + + +
+
+ +

Sibling

+
+ + ) + } + ` + const firstPass = compileHydrate({ + code, + id: file, + root: dir, + env: 'client', + }) + expect( + firstPass?.boundaries.map((boundary) => boundary.exportName), + ).toEqual(['Hydrate_0', 'Hydrate_2']) + + const siblingBoundary = firstPass!.boundaries[1]! + const virtualId = virtualHydrateId(file, siblingBoundary) + const boundaryIndex = siblingBoundary.index + const virtualModule = loadVirtualHydrateModule({ + code, + id: virtualId, + root: dir, + }) + const parentVirtualId = virtualHydrateId(file, firstPass!.boundaries[0]!) + const parentVirtualModule = loadVirtualHydrateModule({ + code, + id: parentVirtualId, + root: dir, + }) + const nestedPass = compileHydrate({ + code: parentVirtualModule!.code, + id: parentVirtualId, + root: dir, + env: 'client', + }) + const serverPass = compileHydrate({ + code, + id: file, + root: dir, + env: 'server', + }) + + expect(boundaryIndex).toBe(2) + expect(virtualModule?.code).toContain('Sibling') + expect(virtualModule?.code).not.toContain('Nested') + expect(nestedPass?.boundaries[0]?.exportName).toBe('Hydrate_1') + expect(serverPass?.code).toContain(firstPass!.boundaries[0]!.id) + expect(serverPass?.code).toContain(nestedPass!.boundaries[0]!.id) + expect(serverPass?.code).toContain(siblingBoundary.id) + }) + + test('loads virtual modules from supplied bundler code instead of the filesystem', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const oldCode = ` + import { Hydrate } from '@tanstack/react-start' + import { visible } from '@tanstack/react-start/hydration' + + export function Page() { + return

Old

+ } + ` + const nextCode = oldCode.replace('Old', 'New') + const firstPass = compileHydrate({ + code: oldCode, + id: file, + root: dir, + env: 'client', + }) + const virtualModule = loadVirtualHydrateModule({ + code: nextCode, + id: virtualHydrateId(file, firstPass!.boundaries[0]!), + root: dir, + }) + + expect(virtualModule?.code).toContain('New') + expect(virtualModule?.code).not.toContain('Old') + }) + + test('captures identifiers used by shorthand objects and computed members', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const code = ` + import { Hydrate } from '@tanstack/react-start' + import { visible } from '@tanstack/react-start/hydration' + + export function Page() { + const key = 'name' + const items = { name: 'Ada' } + return ( + + + + ) + } + + function Widget(props: { data: { key: string; value: string } }) { + return

{props.data.value}

+ } + ` + const firstPass = compileHydrate({ + code, + id: file, + root: dir, + env: 'client', + }) + const virtualModule = loadVirtualHydrateModule({ + code, + id: virtualHydrateId(file, firstPass!.boundaries[0]!), + root: dir, + }) + + expect(virtualModule?.code).toContain('export function Hydrate_0({') + expect(virtualModule?.code).toContain('items') + expect(virtualModule?.code).toContain('key') + expect(virtualModule?.code).toContain('items[key]') + }) + + test('unwraps exported declarations needed by extracted virtual modules', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const code = ` + import { createFileRoute } from '@tanstack/react-router' + import { Hydrate } from '@tanstack/react-start' + import { visible } from '@tanstack/react-start/hydration' + + export const Route = createFileRoute('/test')({ + component: Page, + }) + + export const label = 'Ada' + + export const Widget = () => { + return

{label}

+ } + + function Page() { + return + } + ` + const firstPass = compileHydrate({ + code, + id: file, + root: dir, + env: 'client', + }) + const virtualModule = loadVirtualHydrateModule({ + code, + id: virtualHydrateId(file, firstPass!.boundaries[0]!), + root: dir, + }) + + expect(virtualModule?.code).toContain('const Widget') + expect(virtualModule?.code).toContain('const label') + expect(virtualModule?.code).toContain('') + expect(virtualModule?.code).not.toContain('export const Widget') + expect(virtualModule?.code).not.toContain('createFileRoute') + expect(virtualModule?.code).not.toContain('const Route') + }) + + test('invalidates cached Hydrate source for virtual module loads', () => { + const dir = mkdtempSync(join(tmpdir(), 'hydrate-when-')) + const file = join(dir, 'route.tsx') + const oldCode = ` + import { Hydrate } from '@tanstack/react-start' + import { visible } from '@tanstack/react-start/hydration' + + export function Page() { + return

Old

+ } + ` + const nextCode = oldCode.replace('Old', 'New') + const envName = 'client' + const plugin = createHydrateCompilerPlugin() + const transformed = compileHydrate({ + code: oldCode, + id: file, + root: dir, + env: 'client', + envName, + plugin, + }) + const virtualId = virtualHydrateId(file, transformed!.boundaries[0]!) + + expect( + transformed!.plugin.loadVirtualModule?.({ + id: virtualId, + root: dir, + env: 'client', + envName, + })?.code, + ).toContain('Old') + + transformed!.plugin.invalidateModule?.({ id: file, envName }) + expect(() => + transformed!.plugin.loadVirtualModule?.({ + id: virtualId, + root: dir, + env: 'client', + envName, + }), + ).toThrow(/Missing Hydrate source/) + + const updated = compileHydrate({ + code: nextCode, + id: file, + root: dir, + env: 'client', + envName, + plugin, + }) + + expect( + updated!.plugin.loadVirtualModule?.({ + id: virtualId, + root: dir, + env: 'client', + envName, + })?.code, + ).toContain('New') + }) + + test('rejects virtual module ids whose boundary index and stable id disagree', () => { + const code = ` + import { Hydrate } from '@tanstack/react-start' + import { idle, visible } from '@tanstack/react-start/hydration' + + export function Page() { + return ( + <> +

First

+

Second

+ + ) + } + ` + const firstPass = compileHydrate({ + code, + id, + root, + env: 'client', + }) + const mismatchedId = virtualHydrateId(id, { + ...firstPass!.boundaries[0]!, + index: firstPass!.boundaries[1]!.index, + }) + + expect( + new URLSearchParams(mismatchedId.split('?')[1]).get('tss-hydrate-index'), + ).toBe('1') + expect( + loadVirtualHydrateModule({ code, id: mismatchedId, root }), + ).toBeNull() + }) +}) diff --git a/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenFunctionChild.tsx b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenFunctionChild.tsx new file mode 100644 index 00000000000..a2560a61977 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenFunctionChild.tsx @@ -0,0 +1,6 @@ +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +export function Page() { + return {() =>

child

}
+} diff --git a/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenHookCall.tsx b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenHookCall.tsx new file mode 100644 index 00000000000..1e89bb7303e --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenHookCall.tsx @@ -0,0 +1,14 @@ +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +function useThing() { + return 'thing' +} + +export function Page() { + return ( + +

{useThing()}

+
+ ) +} diff --git a/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenSuperCapture.tsx b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenSuperCapture.tsx new file mode 100644 index 00000000000..63b91b02390 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenSuperCapture.tsx @@ -0,0 +1,16 @@ +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +class Base { + title = 'super' +} + +export class Page extends Base { + render() { + return ( + +

{super.title}

+
+ ) + } +} diff --git a/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenThisCapture.tsx b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenThisCapture.tsx new file mode 100644 index 00000000000..5f7b3df6062 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/error-files/hydrateWhenThisCapture.tsx @@ -0,0 +1,14 @@ +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +export class Page { + title = 'this' + + render() { + return ( + +

{this.title}

+
+ ) + } +} diff --git a/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts new file mode 100644 index 00000000000..b94c8cac102 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts @@ -0,0 +1,233 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import * as t from '@babel/types' +import { generateFromAst, parseAst } from '@tanstack/router-utils' +import { describe, expect, test } from 'vitest' +import { createHydrateCompilerPlugin } from '../../src/hydrate-when-transform' + +const fixtureRoot = path.resolve(import.meta.dirname, './test-files') +const errorRoot = path.resolve(import.meta.dirname, './error-files') + +function fixtureId(filename: string) { + return path.join(fixtureRoot, filename) +} + +async function readFixture(filename: string) { + return await readFile(fixtureId(filename), 'utf8') +} + +async function getFilenames(dirname: string) { + return (await readdir(dirname)) + .filter((filename) => filename.endsWith('.tsx')) + .sort() +} + +type HydrateBoundary = { + id: string + exportName: string + index: number +} + +function getHydrateBoundariesFromCode(code: string): Array { + const boundaries: Array = [] + const hydrateImportPattern = + /import\("([^"]*[?&]tss-hydrate=[^"]*)"\),\s*"([^"]+)"/g + let match: RegExpExecArray | null + + while ((match = hydrateImportPattern.exec(code))) { + const importId = match[1]! + const queryIndex = importId.indexOf('?') + const params = new URLSearchParams(importId.slice(queryIndex + 1)) + const boundaryId = params.get('tss-hydrate') + const rawIndex = params.get('tss-hydrate-index') + const index = rawIndex === null ? Number.NaN : Number(rawIndex) + + if (boundaryId && Number.isInteger(index)) { + boundaries.push({ + id: boundaryId, + exportName: match[2]!, + index, + }) + } + } + + return boundaries.sort((a, b) => a.index - b.index) +} + +function compile(opts: { + env: 'client' | 'server' + code: string + id: string + root?: string +}) { + const options = { + ...opts, + root: opts.root ?? fixtureRoot, + } + const plugin = createHydrateCompilerPlugin() + const ast = parseAst({ code: options.code, sourceFilename: options.id }) + const result = plugin.transformAst?.({ + ast, + code: options.code, + id: options.id, + root: options.root, + env: options.env, + envName: options.env, + mode: 'dev', + framework: 'react', + providerEnvName: 'ssr', + types: t, + parseExpression: (expressionCode) => t.identifier(expressionCode), + }) + if (!result) return null + + const generated = generateFromAst(ast, { + sourceMaps: true, + sourceFileName: options.id, + filename: options.id, + }) + + return { + code: generated.code, + map: generated.map, + boundaries: getHydrateBoundariesFromCode(generated.code), + } +} + +function loadVirtualHydrateModule(options: { + code: string + id: string + root: string +}) { + return createHydrateCompilerPlugin().loadVirtualModule?.({ + code: options.code, + id: options.id, + root: options.root, + env: 'client', + envName: 'client', + }) +} + +function virtualHydrateId( + file: string, + boundary: Pick, +) { + const params = new URLSearchParams() + params.set('tss-hydrate', boundary.id) + params.set('tss-hydrate-index', String(boundary.index)) + return `${file}?${params.toString()}` +} + +describe('Hydrate compiler transform fixtures', async () => { + const filenames = await getFilenames(fixtureRoot) + + describe.each(filenames)('should handle "%s"', async (filename) => { + const code = await readFixture(filename) + const id = fixtureId(filename) + + test(`should compile ${filename} for client`, async () => { + const result = compile({ env: 'client', code, id }) + + await expect(result?.code ?? 'no-transform').toMatchFileSnapshot( + `./snapshots/client/${filename}`, + ) + }) + + test(`should compile ${filename} for server`, async () => { + const result = compile({ env: 'server', code, id }) + + await expect(result?.code ?? 'no-transform').toMatchFileSnapshot( + `./snapshots/server/${filename}`, + ) + }) + }) + + test('should extract virtual modules and keep nested ids stable', async () => { + const filename = 'hydrateWhenNested.tsx' + const code = await readFixture(filename) + const id = fixtureId(filename) + const firstPass = compile({ env: 'client', code, id }) + + expect( + firstPass?.boundaries.map((boundary) => boundary.exportName), + ).toEqual(['Hydrate_0', 'Hydrate_2']) + + for (const boundary of firstPass!.boundaries) { + const virtualId = virtualHydrateId(id, boundary) + const loaded = loadVirtualHydrateModule({ + code, + id: virtualId, + root: fixtureRoot, + }) + + await expect(loaded?.code ?? 'no-virtual-module').toMatchFileSnapshot( + `./snapshots/virtual/${filename}.${boundary.exportName}.tsx`, + ) + } + + const parentBoundary = firstPass!.boundaries[0]! + const parentVirtualId = virtualHydrateId(id, parentBoundary) + const parentVirtualModule = loadVirtualHydrateModule({ + code, + id: parentVirtualId, + root: fixtureRoot, + }) + const nestedPass = compile({ + code: parentVirtualModule!.code, + id: parentVirtualId, + env: 'client', + }) + + await expect(nestedPass?.code ?? 'no-transform').toMatchFileSnapshot( + `./snapshots/virtual/${filename}.Hydrate_0.client.tsx`, + ) + }) +}) + +describe('Hydrate compiler extraction errors', async () => { + const errorCases = [ + { + filename: 'hydrateWhenFunctionChild.tsx', + message: /function-as-children/, + }, + { + filename: 'hydrateWhenHookCall.tsx', + message: /hooks/, + }, + { + filename: 'hydrateWhenThisCapture.tsx', + message: /captures this/, + }, + { + filename: 'hydrateWhenSuperCapture.tsx', + message: /captures super/, + }, + ] as const + + describe.each(errorCases)('$filename', async ({ filename, message }) => { + const code = await readFile(path.join(errorRoot, filename), 'utf8') + const id = path.join(errorRoot, filename) + + test('should reject unsafe client extraction', () => { + expect(() => + compile({ + code, + id, + root: errorRoot, + env: 'client', + }), + ).toThrow(message) + }) + + test('should reject unsafe server extraction', () => { + expect(() => + compile({ + code, + id, + root: errorRoot, + env: 'server', + }), + ).toThrow(message) + }) + }) +}) diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx new file mode 100644 index 00000000000..7e101d56759 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx @@ -0,0 +1,20 @@ +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenBasic.tsx?tss-hydrate=hydrateWhenBasic_ea3b43bf45&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +import { Chart, FallbackPane } from './widgets'; +import { formatValue } from './format'; +const chartTitle = formatValue('Revenue'); +export function Page() { + return
+ } splitId="hydrateWhenBasic_ea3b43bf45" preload={_Hydrate_0_preload}> + {<_Hydrate_ />} + +
; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx new file mode 100644 index 00000000000..d24ce5279aa --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx @@ -0,0 +1,31 @@ +const _Hydrate_3 = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenMultiple.tsx?tss-hydrate=hydrateWhenMultiple_67a68576e2&tss-hydrate-index=2"), "Hydrate_2"), + _Hydrate_2_preload = _Hydrate_3.preload; +const _Hydrate_2 = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenMultiple.tsx?tss-hydrate=hydrateWhenMultiple_b697cf73ee&tss-hydrate-index=1"), "Hydrate_1"), + _Hydrate_1_preload = _Hydrate_2.preload; +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenMultiple.tsx?tss-hydrate=hydrateWhenMultiple_60c0370186&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate } from '@tanstack/react-start'; +import { load, media, visible } from '@tanstack/react-start/hydration'; +function Summary() { + return
Summary
; +} +function Comments() { + return
Comments
; +} +function Footer() { + return
Footer
; +} +export function Page() { + return <> + + {<_Hydrate_ />} + + + {<_Hydrate_2 />} + + + {<_Hydrate_3 />} + + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx new file mode 100644 index 00000000000..0ec68dcd9b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx @@ -0,0 +1,27 @@ +const _Hydrate_2 = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNested.tsx?tss-hydrate=hydrateWhenNested_383a2d73fd&tss-hydrate-index=2"), "Hydrate_2"), + _Hydrate_2_preload = _Hydrate_2.preload; +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNested.tsx?tss-hydrate=hydrateWhenNested_27eaf98771&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, interaction, visible } from '@tanstack/react-start/hydration'; +const unused = 'remove me from virtual modules'; +function Outer() { + return
Outer
; +} +function NestedButton() { + return ; +} +function Sibling() { + return ; +} +export function Page() { + return <> + + {<_Hydrate_ />} + + + {<_Hydrate_2 />} + + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx new file mode 100644 index 00000000000..fd85b1bc153 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx @@ -0,0 +1,13 @@ +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenNever.tsx?tss-hydrate=hydrateWhenNever_9a00c8d701&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate } from '@tanstack/react-start'; +import { never } from '@tanstack/react-start/hydration'; +function StaticMarketingBlock() { + return
Static marketing
; +} +export function Page() { + return + {<_Hydrate_ />} + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNoImport.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNoImport.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNoImport.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNotFromTanstack.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNotFromTanstack.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNotFromTanstack.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx new file mode 100644 index 00000000000..32b4a9c3ef0 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx @@ -0,0 +1,34 @@ +const _Hydrate_3 = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenObjectFallback.tsx?tss-hydrate=hydrateWhenObjectFallback_ff2f1d1b76&tss-hydrate-index=2"), "Hydrate_2"), + _Hydrate_2_preload = _Hydrate_3.preload; +const _Hydrate_2 = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenObjectFallback.tsx?tss-hydrate=hydrateWhenObjectFallback_bdf3670c0a&tss-hydrate-index=1"), "Hydrate_1"), + _Hydrate_1_preload = _Hydrate_2.preload; +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenObjectFallback.tsx?tss-hydrate=hydrateWhenObjectFallback_02e12e6487&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +const spreadProps = { + when: visible(), + fallback:
Bound
+}; +function Widget(props: { + title: string; +}) { + return

{props.title}

; +} +export function Page() { + return <> + Direct} splitId="hydrateWhenObjectFallback_02e12e6487" preload={_Hydrate_0_preload}> + {<_Hydrate_ />} + + Inline + }} splitId="hydrateWhenObjectFallback_bdf3670c0a" preload={_Hydrate_1_preload}> + {<_Hydrate_2 />} + + + {<_Hydrate_3 />} + + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx new file mode 100644 index 00000000000..b911fc01d14 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx @@ -0,0 +1,15 @@ +const _Hydrate_ = _lazyHydratedComponent(() => import("/Users/caligano/source/tanstack/router/packages/start-plugin-core/tests/hydrateWhen/test-files/hydrateWhenRenamed.tsx?tss-hydrate=hydrateWhenRenamed_ad6838514c&tss-hydrate-index=0"), "Hydrate_0"), + _Hydrate_0_preload = _Hydrate_.preload; +import { lazyHydratedComponent as _lazyHydratedComponent } from "@tanstack/react-start"; +import { Hydrate as HW } from '@tanstack/react-start'; +import { interaction } from '@tanstack/react-start/hydration'; +function SearchBox() { + return ; +} +export function Page() { + return + {<_Hydrate_ />} + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalse.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalse.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalse.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalseFunctionChild.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalseFunctionChild.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenSplitFalseFunctionChild.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenWrongImportName.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenWrongImportName.tsx new file mode 100644 index 00000000000..b35c36ab1b3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenWrongImportName.tsx @@ -0,0 +1 @@ +no-transform \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenBasic.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenBasic.tsx new file mode 100644 index 00000000000..1675438ece9 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenBasic.tsx @@ -0,0 +1,17 @@ +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +import { Chart } from './widgets'; +import { formatValue } from './format'; +const chartTitle = formatValue('Revenue'); +export function Page() { + return
+ + + +
; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenMultiple.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenMultiple.tsx new file mode 100644 index 00000000000..7426978843a --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/server/hydrateWhenMultiple.tsx @@ -0,0 +1,24 @@ +import { Hydrate } from '@tanstack/react-start'; +import { load, media, visible } from '@tanstack/react-start/hydration'; +function Summary() { + return
Summary
; +} +function Comments() { + return
Comments
; +} +function Footer() { + return
Footer
; +} +export function Page() { + return <> + + + + + + + +