diff --git a/.changeset/deferred-hydration-start.md b/.changeset/deferred-hydration-start.md new file mode 100644 index 0000000000..90d7680399 --- /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 ba3095f0ae..f222b8088b 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 0000000000..9d87d8748b --- /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 0000000000..ff1da4c304 --- /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/index.tsx b/benchmarks/bundle-size/scenarios/react-start-deferred-hydration/src/routes/index.tsx new file mode 100644 index 0000000000..a462508abd --- /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 { 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 0000000000..d4e4cd980d --- /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 0000000000..aa7ead6752 --- /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 0000000000..e59de72236 --- /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/index.tsx b/benchmarks/bundle-size/scenarios/solid-start-deferred-hydration/src/routes/index.tsx new file mode 100644 index 0000000000..80362386b2 --- /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 { 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 0000000000..0bd21e64f4 --- /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 8872178ce9..b5b0c8753f 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" @@ -246,6 +250,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 0000000000..a0420f0943 --- /dev/null +++ b/docs/start/framework/react/guide/deferred-hydration.md @@ -0,0 +1,701 @@ +--- +id: deferred-hydration +title: Deferred Hydration +--- + +> Deferred hydration is experimental + +On an initial page load, TanStack Start server-renders your page so the browser +can show useful HTML quickly. Hydration is the client-side work that turns that +initial HTML document into an interactive app. It loads and executes JavaScript, +runs components, attaches event handlers, and reconnects the existing DOM to +React. + +Deferred hydration applies to this initial document hydration work. After the +app is already running, subsequent client-side navigations render through the +client app; there is no initial server HTML for TanStack Start to preserve. + +By default, TanStack Start hydrates the full document. That is usually the +simplest and safest behavior, but large pages can spend meaningful startup time +loading JavaScript and hydrating parts of the page that the user may not need +right away. + +Deferred hydration lets you mark selected parts of a page as "not interactive +yet". The server HTML remains in the document, but TanStack Start waits to +hydrate that boundary until a strategy says it is time. By default, the compiler +also moves the boundary children into a separate JavaScript chunk so the browser +can delay loading that code too. + +Use deferred hydration when a part of the page should be visible, styled, and +indexable immediately, but does not need to be interactive immediately. + +## Add A Deferred Boundary + +Use `Hydrate` with a strategy from `@tanstack/react-start/hydration`: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + + + + ) +} +``` + +On the initial server response, `Reviews` is still rendered to HTML. During the +initial client hydration pass, that HTML is preserved but the `Reviews` React +tree does not hydrate yet. When the boundary comes within `400px` of the +viewport, TanStack Start loads the deferred child chunk and hydrates the +boundary. + +`Hydrate` only preserves server HTML that exists in the initial document. If the +same boundary first mounts later, for example after client-side navigation, +there is no server HTML to preserve, so it renders normally on the client. + +## Choose What To Defer + +The right boundary depends on your page, your product priorities, and real user +behavior. TanStack Start cannot know which parts of your page are safe to delay. + +Good candidates are usually SSR content that is not needed for immediate +interaction: + +- Below-the-fold reviews, comments, product details, related content, or long + marketing sections. +- Rich widgets such as maps, charts, carousels, video players, editors, or + embeds. +- Panels that are activated by intent, such as filters, preview panes, or + contextual tools. +- UI that only matters for a matching media query. +- Static server-rendered content that should not hydrate on the initial + document. + +Poor candidates are parts of the page users may need immediately: + +- Primary navigation, route chrome, search boxes, and account controls. +- Above-the-fold forms, add-to-cart buttons, checkout actions, or consent + controls. +- The interactive part of the LCP or 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, context, or shared state are expected to update + immediately after app startup. + +Measure each boundary. A useful boundary reduces startup JavaScript or hydration +work without making expected interactions feel late. + +## The Three Decisions + +Each `Hydrate` boundary has three performance decisions: + +| Decision | Option | What it controls | +| ----------- | ---------- | ------------------------------------------------------------------ | +| Hydration | `when` | When the preserved server HTML becomes interactive. | +| Code split | `split` | Whether the children move into a generated deferred child chunk. | +| Preparation | `prefetch` | Whether work starts before the `when` strategy hydrates the child. | + +### `when`: decide when the boundary hydrates + +`when` is required. Pass a strategy object for the common case: + +```tsx + + + +``` + +Pass a function when the decision needs browser-only information: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { interaction, visible } from '@tanstack/react-start/hydration' + +export function RecommendationsBoundary() { + return ( + + navigator.connection?.saveData + ? interaction({ events: 'click' }) + : visible() + } + > + + + ) +} +``` + +The function form is evaluated only on the client and must synchronously return +a strategy. Use `never()` when you intentionally want the initial server HTML to +stay static. + +### `split`: decide whether to create a separate child chunk + +By default, `Hydrate` splits the children into a generated child chunk: + +```tsx + + + +``` + +This delays both hydration work and child JavaScript loading. + +Set `split={false}` when the child code is small or already needed elsewhere, +and you only want to delay hydration work: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +export function SmallWidgetBoundary() { + return ( + + + + ) +} +``` + +### `prefetch`: decide whether to start loading before hydration + +`prefetch` starts loading before the boundary hydrates. It has two forms: + +| Form | Example | Use it for | +| ------------------- | ----------------------------------- | -------------------------------------------------------------- | +| Prefetch strategy | `prefetch={idle()}` | Preloading the generated child chunk before hydration. | +| Procedural prefetch | `prefetch={async (ctx) => { ... }}` | Preloading the child chunk plus data or other async resources. | + +Both forms start work early, but they do not change when the boundary becomes +interactive. That is still controlled by `when`. + +A prefetch strategy is the small, declarative form: + +```tsx +import { idle, interaction, visible } from '@tanstack/react-start/hydration' + + + + + + + + +``` + +Strategy-form `prefetch` downloads the generated child chunk before the boundary +hydrates. This can make the later hydration trigger feel faster, because the +browser may already have the chunk by the time `when` resolves. Generated child +chunks only exist when `split` is enabled, so TypeScript rejects strategy-form +`prefetch` when `split={false}`. + +Use procedural prefetch when you need custom work: + +```tsx +import { useQueryClient } from '@tanstack/react-query' +import { Hydrate } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' + +function DeferredReviews() { + const queryClient = useQueryClient() + + return ( + { + await preload() + await queryClient.prefetchQuery(reviewsQueryOptions) + }} + > + + + ) +} +``` + +Procedural prefetch also works with `split={false}`. In that case, `preload()` +is a resolved no-op, but the function can still prepare data or other +resources. + +## Common Recipes + +### Hydrate below-the-fold SSR content + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + <> + + + + + + + + ) +} +``` + +Use a positive `rootMargin` when the boundary should hydrate before it actually +enters the viewport. + +### Download the child chunk before it is needed + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { idle, visible } from '@tanstack/react-start/hydration' + +export function ReviewsBoundary() { + return ( + + + + ) +} +``` + +This keeps the boundary non-interactive until it is close to the viewport, but +starts loading the child chunk during idle time. + +### Keep a widget cold until user intent + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { interaction, visible } from '@tanstack/react-start/hydration' + +export function RecommendationsBoundary() { + return ( + + + + ) +} +``` + +This is useful for expensive controls that are visible or nearby, but only +matter when the user reaches for them. + +### Delay hydration without code splitting + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { idle } from '@tanstack/react-start/hydration' + +export function BadgeBoundary() { + return ( + + + + ) +} +``` + +Use this when the JavaScript is already part of the startup bundle or when a +separate child chunk would not be worth it. + +### Keep initial SSR HTML static + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { never } from '@tanstack/react-start/hydration' + +export function MarketingPage() { + return ( + + + + ) +} +``` + +`never()` preserves the existing server HTML and does not hydrate the boundary +during initial document hydration. If the same boundary mounts later during +client-side navigation, it renders normally because there is no initial server +HTML to preserve. `never()` cannot be used as a prefetch strategy. + +### Reuse Hydrate props + +Use `HydrateOptions` for reusable objects that you spread into `Hydrate`: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import type { HydrateOptions } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' + +const belowFoldProps = { + when: () => visible({ rootMargin: '800px' }), +} satisfies HydrateOptions + +export function Page() { + return ( + { + await preload() + }} + > + + + ) +} +``` + +Inline `when` and `prefetch` functions are supported. You do not need to wrap +them in `useCallback`; TanStack Start keeps the latest callback internally and +does not re-register hydration listeners just because a function identity +changed. If the meaning of a boundary changes, use a normal React `key` to +create a new boundary. + +## Hydrate Props Reference + +`Hydrate` accepts these props: + +| Prop | Type | Notes | +| ------------ | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `when` | `HydrationStrategy \| () => HydrationStrategy` | Required. Controls when the boundary hydrates. Function form is client-only and synchronous. | +| `prefetch` | `HydrationPrefetchStrategy \| HydrationPrefetchFunction` | Optional. Strategy form preloads the split child chunk. Function form can preload chunks, data, or other resources, and can be used with `split={false}`. | +| `split` | `boolean` | Defaults to `true`. Set literal `false` to disable compiler extraction and only defer hydration work. | +| `fallback` | `ReactNode` | Client-only loading UI for boundaries that mount after the app has already hydrated and then suspend on the child chunk or child `Suspense`. | +| `onHydrated` | `() => void` | Fires once after the boundary has hydrated on the client. | + +## Strategy Reference + +Import strategies from `@tanstack/react-start/hydration`. + +| 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 configured interaction intent events. | +| `condition()` | Hydrates once the condition is truthy. | +| `never()` | Never hydrates the initial server-rendered boundary. | + +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`. + +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 +import { Hydrate } from '@tanstack/react-start' +import { interaction } from '@tanstack/react-start/hydration' + + + + + + + + +``` + +After a `condition()` boundary hydrates, it stays hydrated even if the condition +later becomes false: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { condition } from '@tanstack/react-start/hydration' + +export function CartRecommendationsBoundary() { + return ( + + + + ) +} +``` + +## Prefetch Reference + +Procedural prefetch receives a context object: + +| Property | Meaning | +| ------------------- | --------------------------------------------------------------------------------------- | +| `preload()` | Loads the compiler-generated child chunk. It resolves immediately when `split={false}`. | +| `waitFor(strategy)` | Waits for a prefetch strategy, the hydration trigger, or abort. | +| `signal` | `AbortSignal` for cancelable async work such as `fetch`. | +| `element` | Boundary marker element for custom observers or DOM measurements. | + +`waitFor(strategy)` resolves with: + +| Result | Meaning | +| ------------ | ------------------------------------------------------------------- | +| `'prefetch'` | The supplied prefetch strategy resolved normally. | +| `'hydrate'` | The boundary's hydration trigger fired first. Do required work now. | +| `'abort'` | The boundary unmounted or the prefetch lifecycle was abandoned. | + +The promise returned from procedural prefetch is meaningful. Awaited work blocks +hydration if the `when` strategy resolves before the prefetch function +finishes: + +```tsx + { + await preload() + }} +> + + +``` + +Fire-and-forget work does not block hydration: + +```tsx + { + void preload() + }} +> + + +``` + +Use this distinction deliberately. Await when the resource is required for the +first hydrated render. Fire and forget when the resource is only a helpful +head start. + +## Fallbacks + +`fallback` is not the placeholder for the initial server-rendered HTML. On the +initial page load, TanStack Start keeps the existing server HTML in place until +the boundary hydrates: + +```tsx +}> + + +``` + +In that example, if `Reviews` was present in the initial HTML document, users +see the server-rendered reviews. They do not see `ReviewsSkeleton` while the +boundary is waiting for `visible()`. + +`fallback` is used when the boundary first appears after the app is already +running and there is no existing server HTML for that boundary. Common examples +include client-side navigation, conditionally showing a panel, or opening a tab +whose contents were not in the initial document. In those cases, the boundary +renders on the client, and `fallback` can show while the generated child chunk +or a child `Suspense` is still loading. + +With `never()`, 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. + +## 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. + +## Nested Boundaries + +Nested boundaries hydrate parent-first. A child boundary can only hydrate after +its ancestor boundaries have hydrated. That means non-interaction child +strategies such as `visible`, `media`, `idle`, or `condition` cannot run while +their parent boundary is still dehydrated. + +For example, a product page might defer the whole reviews section until it is +near the viewport, while keeping heavier review tools cold until the user +interacts with them: + +```tsx +import { Hydrate } from '@tanstack/react-start' +import { interaction, visible } from '@tanstack/react-start/hydration' + +export function ProductPage() { + return ( + <> + + + + +
+

Reviews

+ + + + + + + + + + +
+
+ + ) +} +``` + +In this example, scrolling near the reviews hydrates the parent first. Only +after that can the nested interaction boundaries hydrate from focus or click. + +Interaction intent can also resolve an unresolved ancestor chain when the +ancestor is itself waiting for interaction: + +```tsx + +
+ + + + + +
+
+``` + +If the first meaningful intent is a click inside `WriteReviewForm`, TanStack +Start hydrates the unresolved parent chain and then redispatches a same-type +event for the target boundary. Native listener payload details such as pointer +coordinates are not guaranteed to be preserved. A `never()` ancestor still wins +during initial hydration, so descendants under it remain non-interactive. + +## Preloading And CSS + +Transformed `Hydrate` JavaScript chunks are not modulepreloaded with the route. +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 used by split, deferred, and `never()` boundaries is linked in the SSR HTML +for the matched route. It is not deferred with the generated child JavaScript +chunk, because the server-rendered HTML may need those styles before any +JavaScript runs. This is route-level asset linking: if a route module contains a +deferred boundary that imports CSS, that stylesheet can be linked for the route +even when that boundary is hidden behind conditional rendering and does not +appear in a particular response. + +## 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. + +Keep the component you want to split directly inside `Hydrate`. If you hide it +behind opaque `children` props, the compiler cannot statically extract those +children into a generated child chunk at the usage site. + +The split boundary must use a statically imported `Hydrate` component from +`@tanstack/react-start`. Renaming that import is supported: + +```tsx +import { Hydrate as Deferred } from '@tanstack/react-start' + +export function ProductPage() { + return ( + + + + ) +} +``` + +Assigning `Hydrate` to another component variable is not analyzed for splitting: + +```tsx +import { Hydrate } from '@tanstack/react-start' + +const Deferred = Hydrate + + + + +``` + +Render the imported `Hydrate` tag directly, use an import rename, or set +`split={false}` when you need component indirection. + +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 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. + +You can extract reusable `when` and `prefetch` helpers today, but avoid hiding +split boundaries behind plain wrapper components if you need child code +splitting. A wrapper can defer hydration at runtime, but the compiler cannot +reliably move call-site children into a separate chunk through arbitrary +component indirection. 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 0000000000..99ffc57a86 --- /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/e2e-utils/src/hmrFileEditor.ts b/e2e/e2e-utils/src/hmrFileEditor.ts new file mode 100644 index 0000000000..309940fbfe --- /dev/null +++ b/e2e/e2e-utils/src/hmrFileEditor.ts @@ -0,0 +1,127 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +export function replaceAll(source: string, from: string, to: string) { + return source.split(from).join(to) +} + +export type HmrFileEditorOptions = { + rootDir?: string + files: Record + normalizeSource?: (fileKey: TFileKey, source: string) => string +} + +export function createHmrFileEditor( + options: HmrFileEditorOptions, +) { + const files = Object.fromEntries( + Object.entries(options.files).map(([key, filePath]) => [ + key, + options.rootDir && !path.isAbsolute(filePath as string) + ? path.join(options.rootDir, filePath as string) + : (filePath as string), + ]), + ) as Record + const originalContents: Partial> = {} + const pendingRestoreKeys = new Set() + const normalizeSource = + options.normalizeSource ?? ((_fileKey: TFileKey, source: string) => source) + + async function captureOriginals() { + for (const [key, filePath] of Object.entries(files) as Array< + [TFileKey, string] + >) { + const current = await readFile(filePath, 'utf8') + const normalized = normalizeSource(key, current) + + if (normalized !== current) { + await writeFile(filePath, normalized) + pendingRestoreKeys.add(key) + } + + originalContents[key] = normalized + } + } + + const capturePromise = captureOriginals() + + async function restoreFiles(forceFileKeys: Iterable = []) { + const forceRestoreKeys = new Set(forceFileKeys) + const restoredFileKeys: Array = [] + + for (const [key, filePath] of Object.entries(files) as Array< + [TFileKey, string] + >) { + const content = originalContents[key] + if (content === undefined) continue + + const current = await readFile(filePath, 'utf8') + + if (current !== content || forceRestoreKeys.has(key)) { + await writeFile(filePath, content) + restoredFileKeys.push(key) + } + } + + return restoredFileKeys + } + + async function replaceText(fileKey: TFileKey, from: string, to: string) { + const filePath = files[fileKey] + const source = await readFile(filePath, 'utf8') + + if (!source.includes(from)) { + throw new Error(`Expected file to include ${JSON.stringify(from)}`) + } + + await writeFile(filePath, source.replace(from, to)) + } + + async function rewriteFile( + fileKey: TFileKey, + updater: (source: string) => string, + options: { allowNoop?: boolean } = {}, + ) { + const filePath = files[fileKey] + const source = await readFile(filePath, 'utf8') + const updated = updater(source) + + if (updated === source && !options.allowNoop) { + throw new Error(`Expected ${filePath} to change during rewrite`) + } + + await writeFile(filePath, updated) + } + + async function replaceTextAndWait( + fileKey: TFileKey, + from: string, + to: string, + assertion: () => Promise, + ) { + await replaceText(fileKey, from, to) + await assertion() + } + + async function rewriteFileAndWait( + fileKey: TFileKey, + updater: (source: string) => string, + assertion: () => Promise, + options: { allowNoop?: boolean } = {}, + ) { + await rewriteFile(fileKey, updater, options) + await assertion() + } + + return { + files, + pendingRestoreKeys, + capturePromise, + captureOriginals, + restoreFiles, + replaceText, + replaceTextAndWait, + rewriteFile, + rewriteFileAndWait, + } +} diff --git a/e2e/e2e-utils/src/index.ts b/e2e/e2e-utils/src/index.ts index ec6affdb56..17e58f82e5 100644 --- a/e2e/e2e-utils/src/index.ts +++ b/e2e/e2e-utils/src/index.ts @@ -4,5 +4,7 @@ export { toRuntimePath } from './to-runtime-path' export { resolveRuntimeSuffix } from './resolve-runtime-suffix' export { e2eStartDummyServer, e2eStopDummyServer } from './e2eSetupTeardown' export { preOptimizeDevServer, waitForServer } from './devServerWarmup' +export { createHmrFileEditor, replaceAll } from './hmrFileEditor' +export type { HmrFileEditorOptions } from './hmrFileEditor' export type { Post } from './posts' export { collectBrowserErrors, test } from './fixture' diff --git a/e2e/react-start/deferred-hydration/.gitignore b/e2e/react-start/deferred-hydration/.gitignore new file mode 100644 index 0000000000..1b3a07ede1 --- /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 0000000000..db76b2fecf --- /dev/null +++ b/e2e/react-start/deferred-hydration/package.json @@ -0,0 +1,60 @@ +{ + "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": "pnpm start:vite", + "start:vite": "srvx --prod --dir=. -s dist-vite-ssr/client --entry dist-vite-ssr/server/server.js", + "start:rsbuild": "srvx --prod --dir=. -s dist-rsbuild-ssr/client --entry dist-rsbuild-ssr/server/index.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" + }, + "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 0000000000..0125792d4b --- /dev/null +++ b/e2e/react-start/deferred-hydration/playwright.config.ts @@ -0,0 +1,52 @@ +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}` +const mode = process.env.MODE ?? 'prod' +const isDev = mode === 'dev' +const serverEntryFile = toolchain === 'rsbuild' ? 'index.js' : 'server.js' +const startCommand = `pnpm exec srvx --prod --dir=. -s ${distDir}/client --entry ${distDir}/server/${serverEntryFile}` +const devCommand = + toolchain === 'rsbuild' ? 'pnpm dev:rsbuild' : 'pnpm dev:vite' + +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']], + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + use: { baseURL }, + webServer: { + command: isDev ? `${devCommand} --port ${PORT}` : startCommand, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + E2E_DIST_DIR: distDir, + NODE_ENV: isDev ? 'development' : 'production', + VITE_NODE_ENV: 'test', + PORT: String(PORT), + VITE_SERVER_PORT: String(PORT), + E2E_TOOLCHAIN: toolchain, + E2E_PORT_KEY: e2ePortKey, + }, + }, + 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 0000000000..6279705adb --- /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/src/routeTree.gen.ts b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts new file mode 100644 index 0000000000..a5b35a698f --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routeTree.gen.ts @@ -0,0 +1,177 @@ +/* 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 ScrollRestorationRouteImport } from './routes/scroll-restoration' +import { Route as ImportedRouteImport } from './routes/imported' +import { Route as EnhancedRouteImport } from './routes/enhanced' +import { Route as CssRouteImport } from './routes/css' +import { Route as ComponentsRouteImport } from './routes/components' +import { Route as IndexRouteImport } from './routes/index' + +const ScrollRestorationRoute = ScrollRestorationRouteImport.update({ + id: '/scroll-restoration', + path: '/scroll-restoration', + getParentRoute: () => rootRouteImport, +} as any) +const ImportedRoute = ImportedRouteImport.update({ + id: '/imported', + path: '/imported', + getParentRoute: () => rootRouteImport, +} as any) +const EnhancedRoute = EnhancedRouteImport.update({ + id: '/enhanced', + path: '/enhanced', + 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 + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute + '/scroll-restoration': typeof ScrollRestorationRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute + '/scroll-restoration': typeof ScrollRestorationRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute + '/scroll-restoration': typeof ScrollRestorationRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/components' + | '/css' + | '/enhanced' + | '/imported' + | '/scroll-restoration' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/components' + | '/css' + | '/enhanced' + | '/imported' + | '/scroll-restoration' + id: + | '__root__' + | '/' + | '/components' + | '/css' + | '/enhanced' + | '/imported' + | '/scroll-restoration' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ComponentsRoute: typeof ComponentsRoute + CssRoute: typeof CssRoute + EnhancedRoute: typeof EnhancedRoute + ImportedRoute: typeof ImportedRoute + ScrollRestorationRoute: typeof ScrollRestorationRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/scroll-restoration': { + id: '/scroll-restoration' + path: '/scroll-restoration' + fullPath: '/scroll-restoration' + preLoaderRoute: typeof ScrollRestorationRouteImport + parentRoute: typeof rootRouteImport + } + '/imported': { + id: '/imported' + path: '/imported' + fullPath: '/imported' + preLoaderRoute: typeof ImportedRouteImport + parentRoute: typeof rootRouteImport + } + '/enhanced': { + id: '/enhanced' + path: '/enhanced' + fullPath: '/enhanced' + preLoaderRoute: typeof EnhancedRouteImport + 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, + EnhancedRoute: EnhancedRoute, + ImportedRoute: ImportedRoute, + ScrollRestorationRoute: ScrollRestorationRoute, +} +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 0000000000..9d87d8748b --- /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 0000000000..bfdc646fc5 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,180 @@ +/// +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 0000000000..95fa7531a4 --- /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 0000000000..2c01aa9e57 --- /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 0000000000..05eafda03c --- /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 0000000000..98ca5e0934 --- /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 0000000000..020da5d7ad --- /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/enhanced.tsx b/e2e/react-start/deferred-hydration/src/routes/enhanced.tsx new file mode 100644 index 0000000000..628a5c4ae3 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/enhanced.tsx @@ -0,0 +1,339 @@ +import { createFileRoute } from '@tanstack/react-router' +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start' +import { interaction, media } from '@tanstack/react-start/hydration' +import { EnhancedNestedWidget } from '../shared/EnhancedNestedWidget' + +type EnhancedSearch = { + dynamic?: 'interaction' +} + +export const Route = createFileRoute('/enhanced')({ + validateSearch: (search: Record): EnhancedSearch => ({ + dynamic: search.dynamic === 'interaction' ? 'interaction' : undefined, + }), + component: EnhancedHydrationPage, +}) + +type DeferredGate = { + promise: Promise + resolve: () => void +} + +const clickIntent = interaction({ events: 'click' }) +const pointerOverIntent = interaction({ events: 'pointerover' }) +const doubleClickIntent = interaction({ events: 'dblclick' }) + +function createDeferredGate(): DeferredGate { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function useDeferredGate() { + const ref = React.useRef(undefined) + ref.current ??= createDeferredGate() + return ref.current +} + +function mergeStatus>( + setStatus: React.Dispatch>, + patch: Partial, +) { + setStatus((current) => ({ ...current, ...patch })) +} + +function InteractiveBox(props: { id: string; label: string }) { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +function DynamicWhenExamples() { + const search = Route.useSearch() + const searchDrivenHydration = React.useCallback( + () => (search.dynamic === 'interaction' ? clickIntent : doubleClickIntent), + [search.dynamic], + ) + + return ( + <> +

+ Dynamic callbacks are client-only. The first boundary always hydrates on + click; the second reads typed router search state before choosing its + interaction event. +

+ clickIntent}> + + + + + + + ) +} + +function SplitPrefetchExample() { + const gate = useDeferredGate() + const [status, setStatus] = React.useState({ + wait: 'idle', + preload: 'idle', + query: 'idle', + }) + + return ( + <> +

{status.wait}

+

{status.preload}

+

{status.query}

+ + { + mergeStatus(setStatus, { + query: element ? 'element' : 'missing-element', + wait: 'waiting', + }) + + const reason = await waitFor(pointerOverIntent) + mergeStatus(setStatus, { wait: reason }) + if (reason === 'abort' || signal.aborted) return + + await preload() + mergeStatus(setStatus, { preload: 'done' }) + await gate.promise + mergeStatus(setStatus, { query: 'done' }) + }} + > + + + + ) +} + +function FireAndForgetPrefetchExample() { + const gate = useDeferredGate() + const [status, setStatus] = React.useState({ + wait: 'idle', + work: 'idle', + query: 'idle', + }) + + return ( + <> +

{status.work}

+

{status.wait}

+

{status.query}

+ + { + mergeStatus(setStatus, { wait: 'waiting' }) + void waitFor(pointerOverIntent).then((reason) => { + mergeStatus(setStatus, { wait: reason }) + if (reason === 'abort') return + + mergeStatus(setStatus, { work: 'started' }) + void gate.promise.then(() => { + mergeStatus(setStatus, { query: 'done' }) + }) + }) + }} + > + + + + ) +} + +function HydrateFirstPrefetchExample() { + const [reason, setReason] = React.useState('idle') + + return ( + <> +

{reason}

+ { + setReason(await waitFor(doubleClickIntent)) + }} + > + + + + ) +} + +function RuntimeOnlyPrefetchExample() { + const gate = useDeferredGate() + const [status, setStatus] = React.useState({ + wait: 'idle', + ready: 'idle', + }) + + return ( + <> +

{status.wait}

+

{status.ready}

+ + { + mergeStatus(setStatus, { wait: 'waiting' }) + const reason = await waitFor(pointerOverIntent) + mergeStatus(setStatus, { wait: reason }) + if (reason === 'abort') return + + await gate.promise + await preload() + mergeStatus(setStatus, { ready: 'ready' }) + }} + > + + + + ) +} + +function WaitForAbortExample() { + const [showBoundary, setShowBoundary] = React.useState(true) + const [reason, setReason] = React.useState('idle') + + return ( + <> +

{reason}

+ + {showBoundary ? ( + { + setReason('waiting') + setReason(await waitFor(pointerOverIntent)) + }} + > + + + ) : null} + + ) +} + +function SignalAbortExample() { + const [showBoundary, setShowBoundary] = React.useState(true) + const [status, setStatus] = React.useState('idle') + + return ( + <> +

{status}

+ + {showBoundary ? ( + { + setStatus('listening') + await new Promise((resolve) => { + const onAbort = () => { + setStatus('aborted') + resolve() + } + + if (signal.aborted) { + onAbort() + return + } + + signal.addEventListener('abort', onAbort, { once: true }) + }) + }} + > + + + ) : null} + + ) +} + +function NestedDynamicExamples() { + return ( + <> + + clickIntent}> + + + + + + ) +} + +function EnhancedHydrationPage() { + return ( +
+

Enhanced Hydrate APIs

+ + + + + + + + +
+ ) +} 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 0000000000..bf0f8b74bd --- /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 0000000000..0585f4bc87 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/index.tsx @@ -0,0 +1,23 @@ +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 + +

Enhanced APIs

+ enhanced Hydrate APIs +
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx b/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx new file mode 100644 index 0000000000..7b52cf25fe --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/routes/scroll-restoration.tsx @@ -0,0 +1,93 @@ +import { ClientOnly, createFileRoute } from '@tanstack/react-router' +import { Hydrate } from '@tanstack/react-start' +import { visible } from '@tanstack/react-start/hydration' +import * as React from 'react' + +export const Route = createFileRoute('/scroll-restoration')({ + component: ScrollRestorationRoute, +}) + +function BottomWidget() { + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( +
+

Bottom widget

+

+ This visible deferred boundary is intentionally near the bottom of the + document. +

+

+ The hydrated widget is taller than the server skeleton so the test can + catch scroll restoration that happens before late hydration settles. +

+
+ ) +} + +function BottomWidgetSkeleton() { + return ( +
+

Bottom widget skeleton

+

+ The server-rendered placeholder reserves the same vertical space as the + hydrated widget. +

+
+ ) +} + +function ScrollRestorationRoute() { + return ( +
+

+ Scroll restoration deferred hydration +

+

+ This route keeps the reproduction small: a tall page, a visible Hydrate + boundary near the bottom, and normal router scroll restoration. +

+
+ Scroll to the bottom widget +
+ + }> + + + +
+ ) +} diff --git a/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx b/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx new file mode 100644 index 0000000000..fb2f094e51 --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start' +import { interaction, media } from '@tanstack/react-start/hydration' + +function CrossFileNestedButton() { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + return ( + + ) +} + +export function EnhancedNestedWidget() { + return ( + + + + + + ) +} 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 0000000000..f2be89ec1c --- /dev/null +++ b/e2e/react-start/deferred-hydration/src/shared/ImportedHydrateWidget.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { Hydrate } from '@tanstack/react-start' +import { interaction } from '@tanstack/react-start/hydration' + +function ImportedHydrateChild() { + const [count, setCount] = React.useState(0) + const [hydrated, setHydrated] = React.useState(false) + + React.useEffect(() => { + setHydrated(true) + }, []) + + 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 0000000000..0a2c332271 --- /dev/null +++ b/e2e/react-start/deferred-hydration/tests/hydration.spec.ts @@ -0,0 +1,1028 @@ +import { expect } from '@playwright/test' +import { createHmrFileEditor, test } from '@tanstack/router-e2e-utils' +import crypto from 'node:crypto' +import path from 'node:path' +import type { APIRequestContext, Page } from '@playwright/test' + +const isDev = process.env.MODE === 'dev' +const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' +const isVite = toolchain === 'vite' +const hmrExpect = expect.configure({ timeout: 20_000 }) +const componentsRouteFile = path.join( + process.cwd(), + 'src/routes/components.tsx', +) +const interactiveBoxLabelSource = + ' {props.label}: {count}' +const interactiveBoxHmrLabelSource = + ' hmr {props.label}: {count}' + +function normalizeComponentsRouteSource(source: string) { + return source + .split(interactiveBoxHmrLabelSource) + .join(interactiveBoxLabelSource) +} + +const componentsRouteEditor = createHmrFileEditor({ + files: { + componentsRoute: componentsRouteFile, + }, + normalizeSource: (_fileKey, source) => normalizeComponentsRouteSource(source), +}) + +function getVisibleHydrateVirtualPath() { + const normalizedSourcePath = path + .relative(process.cwd(), componentsRouteFile) + .replaceAll('\\', '/') + const sourceHash = crypto + .createHash('sha1') + .update(normalizedSourcePath) + .digest('hex') + .slice(0, 10) + const params = new URLSearchParams() + params.set('tss-hydrate', `0_${sourceHash}`) + + return `${componentsRouteFile}?${params.toString()}` +} + +async function waitForVisibleHydrateVirtualModule(page: Page, marker: string) { + const virtualPath = getVisibleHydrateVirtualPath() + + await expect + .poll( + async () => { + try { + const response = await page.request.get(virtualPath) + const text = await response.text() + + if (response.ok() && text.includes(marker)) { + return 'ready' + } + + return `${response.status()} ${text.slice(0, 240)}` + } catch (error) { + return String(error) + } + }, + { timeout: 20_000 }, + ) + .toBe('ready') +} + +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 clickIntentAndExpectReplayedCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId(countTestId)).toHaveText(count) +} + +async function clickToHydrateThenClickAndExpectIncrement( + page: Page, + buttonTestId: string, + countTestId: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + const previousCount = Number( + await page.getByTestId(countTestId).textContent(), + ) + await page.getByTestId(buttonTestId).click() + await expect + .poll(async () => Number(await page.getByTestId(countTestId).textContent())) + .toBe(previousCount + 1) +} + +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)) +} + +async function waitForComponentsServerHtmlText(page: Page, text: string) { + await expect + .poll( + async () => { + const response = await page.request.get('/components') + const html = await response.text() + + if (!response.ok()) { + return `${response.status()} ${html.slice(0, 240)}` + } + + try { + htmlContainsText(html, text) + return 'ready' + } catch { + return html.slice(0, 240) + } + }, + { timeout: 20_000 }, + ) + .toBe('ready') +} + +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) +} + +async function gotoEnhanced(page: Page, search = '') { + await page.goto(`/enhanced${search}`) + await expectClientRouterReady(page) +} + +test.describe('Hydrate HMR', () => { + test.skip(!isDev, 'HMR regression coverage runs against the dev server only') + + test.beforeAll(async () => { + await componentsRouteEditor.capturePromise + }) + + test.afterEach(async () => { + await componentsRouteEditor.capturePromise + await componentsRouteEditor.restoreFiles() + }) + + test.afterAll(async () => { + await componentsRouteEditor.capturePromise + await componentsRouteEditor.restoreFiles() + }) + + test('updates deferred child chunks after the parent route is edited', async ({ + page, + }) => { + const pageErrors: Array = [] + page.on('pageerror', (error) => { + pageErrors.push(error.message) + }) + + await page.goto('/components') + await expectClientRouterReady(page) + await expectRouteToStayUnhydrated(page, 'component-visible-button') + + await componentsRouteEditor.replaceText( + 'componentsRoute', + interactiveBoxLabelSource, + interactiveBoxHmrLabelSource, + ) + + await waitForComponentsServerHtmlText(page, 'hmr visible') + if (isVite) { + await waitForVisibleHydrateVirtualModule(page, 'hmr ') + } + + await page.goto('/components') + await expectClientRouterReady(page) + await expectRouteToStayUnhydrated(page, 'component-visible-button') + await scrollToBoundary(page, 'component-visible-button') + await hmrExpect(page.getByTestId('component-visible-button')).toContainText( + 'hmr visible', + ) + await clickAndExpectCount( + page, + 'component-visible-button', + 'component-visible-count', + '1', + ) + expect(pageErrors).toEqual([]) + }) +}) + +test.describe('component-level Hydrate runtime strategies', () => { + test.skip( + isDev, + 'production hydration coverage runs against the preview server', + ) + + 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('preserves scroll position after a force reload on a visible boundary', async ({ + page, + }) => { + await page.goto('/scroll-restoration') + await expectClientRouterReady(page) + await page + .getByTestId('scroll-restoration-skeleton') + .scrollIntoViewIfNeeded() + await expect(page.getByTestId('scroll-restoration-widget')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + + const beforeReloadDistanceFromBottom = await page.evaluate( + () => + document.documentElement.scrollHeight - + window.innerHeight - + window.scrollY, + ) + expect(beforeReloadDistanceFromBottom).toBeLessThan(5) + + const client = await page.context().newCDPSession(page) + const reloaded = page.waitForEvent('load') + await client.send('Page.reload', { ignoreCache: true }) + await reloaded + + await expectClientRouterReady(page) + await expect(page.getByTestId('scroll-restoration-widget')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + + await expect + .poll(() => + page.evaluate( + () => + document.documentElement.scrollHeight - + window.innerHeight - + window.scrollY, + ), + ) + .toBeLessThan(20) + }) +}) + +test.describe('enhanced Hydrate API combinations', () => { + test.skip( + isDev, + 'production hydration coverage runs against the preview server', + ) + + test('server renders dynamic markers without evaluating client-only callbacks or prefetch functions', async ({ + request, + }) => { + const response = await request.get('/enhanced?dynamic=interaction') + const html = await response.text() + + expect(response.ok()).toBe(true) + htmlContainsText(html, 'Enhanced Hydrate APIs') + htmlContainsText(html, 'conditional dynamic') + expect(html).toContain('data-ts-hydrate-when="dynamic"') + expect(html).not.toContain('missing-element') + }) + + test('dynamic when functions hydrate and replay interaction events', async ({ + page, + }) => { + await gotoEnhanced(page, '?dynamic=interaction') + await expect(page.getByTestId('enhanced-heading')).toHaveText( + 'Enhanced Hydrate APIs', + ) + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-dynamic-interaction-button', + 'enhanced-dynamic-interaction-count', + '1', + ) + + await expectRouteToStayUnhydrated( + page, + 'enhanced-dynamic-conditional-button', + ) + await page.getByTestId('enhanced-dynamic-conditional-button').hover() + await expectRouteToStayUnhydrated( + page, + 'enhanced-dynamic-conditional-button', + ) + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-dynamic-conditional-button', + 'enhanced-dynamic-conditional-count', + '1', + ) + }) + + test('procedural prefetch can block hydration, preload the split chunk, and prepare query-like work', async ({ + page, + request, + }) => { + await gotoEnhanced(page) + await expect( + resourceContentsContain( + page, + request, + 'enhanced-procedural-split-child', + isClientJavaScriptResource, + ), + ).resolves.toBe(false) + + await expectRouteToStayUnhydrated(page, 'enhanced-procedural-split-button') + await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-procedural-split-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-split-query')).toHaveText('element') + await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText( + 'prefetch', + ) + await expect(page.getByTestId('enhanced-split-preload')).toHaveText('done') + await expect + .poll(() => + resourceContentsContain( + page, + request, + 'enhanced-procedural-split-child', + isClientJavaScriptResource, + ), + ) + .toBe(true) + + await page.getByTestId('enhanced-procedural-split-button').click() + await expect( + page.getByTestId('enhanced-procedural-split-button'), + ).toHaveAttribute('data-hydrated', 'false') + await expect( + page.getByTestId('enhanced-procedural-split-count'), + ).toHaveText('0') + await page.getByTestId('enhanced-release-split-prefetch').click() + await expect( + page.getByTestId('enhanced-procedural-split-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect( + page.getByTestId('enhanced-procedural-split-count'), + ).toHaveText('1') + await expect(page.getByTestId('enhanced-split-query')).toHaveText('done') + }) + + test('function prefetch supports fire-and-forget work and waitFor hydrate-first resolution', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-fire-and-forget-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText( + 'prefetch', + ) + await expect(page.getByTestId('enhanced-fire-status')).toHaveText('started') + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-fire-and-forget-button', + 'enhanced-fire-and-forget-count', + '1', + ) + await expect(page.getByTestId('enhanced-fire-query')).toHaveText('idle') + await page.getByTestId('enhanced-release-fire-prefetch').click() + await expect(page.getByTestId('enhanced-fire-query')).toHaveText('done') + + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-hydrate-first-button', + 'enhanced-hydrate-first-count', + '1', + ) + await expect(page.getByTestId('enhanced-hydrate-first-reason')).toHaveText( + 'hydrate', + ) + }) + + test('split=false procedural prefetch blocks hydration without requiring a child preload chunk', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expectRouteToStayUnhydrated(page, 'enhanced-runtime-only-button') + await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-runtime-only-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText( + 'prefetch', + ) + await page.getByTestId('enhanced-runtime-only-button').click() + await expect( + page.getByTestId('enhanced-runtime-only-button'), + ).toHaveAttribute('data-hydrated', 'false') + await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText( + '0', + ) + await page.getByTestId('enhanced-release-runtime-prefetch').click() + await expect(page.getByTestId('enhanced-runtime-status')).toHaveText( + 'ready', + ) + await expect( + page.getByTestId('enhanced-runtime-only-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText( + '1', + ) + }) + + test('procedural prefetch aborts waiters and signals when boundaries unmount', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expectRouteToStayUnhydrated(page, 'enhanced-wait-abort-button') + await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText( + 'waiting', + ) + await page.getByTestId('enhanced-hide-wait-abort').click() + await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText( + 'abort', + ) + + await expect(page.getByTestId('enhanced-abort-status')).toHaveText( + 'listening', + ) + await page.getByTestId('enhanced-hide-abort').click() + await expect(page.getByTestId('enhanced-abort-status')).toHaveText( + 'aborted', + ) + }) + + test('nested dynamic interaction boundaries delegate through outer boundaries', async ({ + page, + }) => { + await gotoEnhanced(page) + + await clickToHydrateThenClickAndExpectIncrement( + page, + 'enhanced-dynamic-nested-button', + 'enhanced-dynamic-nested-count', + ) + await clickToHydrateThenClickAndExpectIncrement( + page, + 'enhanced-cross-file-nested-button', + 'enhanced-cross-file-nested-count', + ) + }) +}) + +test.describe('Hydrate CSS delivery', () => { + test.skip( + isDev, + 'production hydration coverage runs against the preview server', + ) + + 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.skip( + isDev, + 'production hydration coverage runs against the preview server', + ) + + 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 clickAndExpectCount( + page, + 'imported-hydrate-child', + 'imported-hydrate-count', + '1', + ) + }) + + test('hydrates imported child boundaries from the initial document on interaction', async ({ + page, + request, + }) => { + await page.goto('/imported') + await expect(page.getByTestId('imported-heading')).toHaveText( + 'Imported Hydrate', + ) + await expectClientRouterReady(page) + await expect(page.getByTestId('imported-hydrate-fallback')).toHaveCount(0) + await expectRouteToStayUnhydrated(page, 'imported-hydrate-child') + await expect( + resourceContentsContain(page, request, 'imported-hydrate-child', (url) => + isClientJavaScriptResource(url), + ), + ).resolves.toBe(false) + + await page.getByTestId('imported-hydrate-child').click() + await expect(page.getByTestId('imported-hydrate-child')).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId('imported-hydrate-count')).toHaveText('1') + await expect + .poll(() => + resourceContentsContain( + page, + request, + 'imported-hydrate-child', + isClientJavaScriptResource, + ), + ) + .toBe(true) + }) +}) diff --git a/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts b/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts new file mode 100644 index 0000000000..1117664a14 --- /dev/null +++ b/e2e/react-start/deferred-hydration/tests/setup/global.setup.ts @@ -0,0 +1,38 @@ +import { + e2eStartDummyServer, + getTestServerPort, + preOptimizeDevServer, + waitForServer, +} from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +function getE2EPortKey() { + const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' + return process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}` +} + +export default async function setup() { + if (process.env.MODE !== 'dev') return + + const e2ePortKey = getE2EPortKey() + + await e2eStartDummyServer(e2ePortKey) + + const port = await getTestServerPort(e2ePortKey) + const baseURL = `http://localhost:${port}` + + await waitForServer(baseURL) + await preOptimizeDevServer({ + baseURL, + readyTestId: 'home-heading', + warmup: async (page) => { + await page.goto(`${baseURL}/components`, { + waitUntil: 'domcontentloaded', + }) + await page.getByTestId('component-heading').waitFor({ + state: 'visible', + }) + await page.waitForLoadState('networkidle') + }, + }) +} diff --git a/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts b/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts new file mode 100644 index 0000000000..df79f50e82 --- /dev/null +++ b/e2e/react-start/deferred-hydration/tests/setup/global.teardown.ts @@ -0,0 +1,13 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +function getE2EPortKey() { + const toolchain = process.env.E2E_TOOLCHAIN ?? 'vite' + return process.env.E2E_PORT_KEY ?? `${packageJson.name}-${toolchain}` +} + +export default async function teardown() { + if (process.env.MODE !== 'dev') return + + await e2eStopDummyServer(getE2EPortKey()) +} diff --git a/e2e/react-start/deferred-hydration/tsconfig.json b/e2e/react-start/deferred-hydration/tsconfig.json new file mode 100644 index 0000000000..cef9369516 --- /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 0000000000..1289bc0be7 --- /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/hmr/tests/app.spec.ts b/e2e/react-start/hmr/tests/app.spec.ts index b3ac8b6a1a..95914f6270 100644 --- a/e2e/react-start/hmr/tests/app.spec.ts +++ b/e2e/react-start/hmr/tests/app.spec.ts @@ -1,7 +1,10 @@ -import { readFile, writeFile } from 'node:fs/promises' -import path from 'node:path' import { expect } from '@playwright/test' -import { test } from '@tanstack/router-e2e-utils' +import { + createHmrFileEditor, + replaceAll, + test, +} from '@tanstack/router-e2e-utils' +import path from 'node:path' import type { Page } from '@playwright/test' @@ -115,14 +118,6 @@ const routeFileRestoreChecks: Partial< }, } -// Capture original file contents once so beforeEach can restore them -const originalContents: Partial> = {} -const routeKeysPendingRestoreCheck = new Set() - -function replaceAll(source: string, from: string, to: string) { - return source.split(from).join(to) -} - function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { let next = source @@ -254,59 +249,14 @@ function normalizeRouteSource(routeFileKey: RouteFileKey, source: string) { return next } -async function captureOriginals() { - for (const [key, filePath] of Object.entries(routeFiles) as Array< - [RouteFileKey, string] - >) { - const current = await readFile(filePath, 'utf8') - const normalized = normalizeRouteSource(key, current) - if (normalized !== current) { - await writeFile(filePath, normalized) - routeKeysPendingRestoreCheck.add(key) - } - originalContents[key] = normalized - } -} - -const capturePromise = captureOriginals() - -async function restoreRouteFiles( - forceRouteFileKeys: Iterable = [], -) { - const forceRestoreKeys = new Set(forceRouteFileKeys) - const restoredRouteKeys: Array = [] - - for (const [key, filePath] of Object.entries(routeFiles) as Array< - [RouteFileKey, string] - >) { - const content = originalContents[key] - if (content === undefined) continue - const current = await readFile(filePath, 'utf8') - // Re-emit pending restores in case the watcher coalesced the previous - // restore write and the dev server is still serving stale route options. - if (current !== content || forceRestoreKeys.has(key)) { - await writeFile(filePath, content) - restoredRouteKeys.push(key) - } - } - - return restoredRouteKeys -} - -async function replaceRouteText( - routeFileKey: RouteFileKey, - from: string, - to: string, -) { - const filePath = routeFiles[routeFileKey] - const source = await readFile(filePath, 'utf8') - - if (!source.includes(from)) { - throw new Error(`Expected route file to include ${JSON.stringify(from)}`) - } - - await writeFile(filePath, source.replace(from, to)) -} +const routeFileEditor = createHmrFileEditor({ + files: routeFiles, + normalizeSource: normalizeRouteSource, +}) +const capturePromise = routeFileEditor.capturePromise +const routeKeysPendingRestoreCheck = routeFileEditor.pendingRestoreKeys +const restoreRouteFiles = routeFileEditor.restoreFiles +const replaceRouteText = routeFileEditor.replaceText async function replaceRouteTextAndWait( page: Page, @@ -315,7 +265,7 @@ async function replaceRouteTextAndWait( to: string, assertion: () => Promise, ) { - await replaceRouteText(routeFileKey, from, to) + await routeFileEditor.replaceText(routeFileKey, from, to) await assertion() } @@ -326,17 +276,7 @@ async function rewriteRouteFile( assertion: () => Promise, options: { allowNoop?: boolean } = {}, ) { - const filePath = routeFiles[routeFileKey] - const source = await readFile(filePath, 'utf8') - const updated = updater(source) - - if (updated === source && !options.allowNoop) { - throw new Error(`Expected ${filePath} to change during rewrite`) - } - - // Even a no-op write is useful for tests that need to force the dev server - // to reconcile a stale in-memory module with the current file contents. - await writeFile(filePath, updated) + await routeFileEditor.rewriteFile(routeFileKey, updater, options) await assertion() } diff --git a/e2e/react-start/rsc-deferred-hydration/.gitignore b/e2e/react-start/rsc-deferred-hydration/.gitignore new file mode 100644 index 0000000000..cc99170fbb --- /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 0000000000..7944b81552 --- /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 0000000000..10f41cd911 --- /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 0000000000..6365c568d4 --- /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 0000000000..d80e17aebe --- /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 0000000000..7f023bd10c --- /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' +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 0000000000..0a716fe39c --- /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' +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)') +} + +export 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 0000000000..246737b515 --- /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 0000000000..9d87d8748b --- /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 0000000000..7ce2e7bdb1 --- /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 0000000000..3241ee487d --- /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 0000000000..ddb22f318a --- /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 0000000000..ebe57e7028 --- /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 0000000000..f39b635241 --- /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 0000000000..d4e431061a --- /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 0000000000..dc785a16ce --- /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 0000000000..2d543c5406 --- /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 0000000000..3d1579ee87 --- /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 0000000000..62fd79911c --- /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 0000000000..cef9369516 --- /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 0000000000..1919761bc3 --- /dev/null +++ b/e2e/react-start/rsc-deferred-hydration/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import rsc from '@vitejs/plugin-rsc' + +const outDir = process.env.E2E_DIST_DIR ?? 'dist' + +export default defineConfig({ + resolve: { tsconfigPaths: true }, + build: { + outDir, + }, + server: { + port: Number(process.env.VITE_SERVER_PORT ?? 3000), + }, + plugins: [ + tanstackStart({ + rsc: { + enabled: true, + }, + }), + rsc(), + viteReact(), + ], +}) diff --git a/e2e/solid-start/deferred-hydration/.gitignore b/e2e/solid-start/deferred-hydration/.gitignore new file mode 100644 index 0000000000..1b3a07ede1 --- /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 0000000000..77f55458fe --- /dev/null +++ b/e2e/solid-start/deferred-hydration/package.json @@ -0,0 +1,56 @@ +{ + "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": "pnpm start:vite", + "start:vite": "srvx --prod --dir=. -s dist-vite-ssr/client --entry dist-vite-ssr/server/server.js", + "start:rsbuild": "srvx --prod --dir=. -s dist-rsbuild-ssr/client --entry dist-rsbuild-ssr/server/index.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 0000000000..ea9e73a856 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/playwright.config.ts @@ -0,0 +1,43 @@ +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}` +const serverEntryFile = toolchain === 'rsbuild' ? 'index.js' : 'server.js' +const startCommand = `pnpm exec srvx --prod --dir=. -s ${distDir}/client --entry ${distDir}/server/${serverEntryFile}` + +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: startCommand, + 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 0000000000..7ab152bb58 --- /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/src/routeTree.gen.ts b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts new file mode 100644 index 0000000000..e75225b500 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routeTree.gen.ts @@ -0,0 +1,140 @@ +/* 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 EnhancedRouteImport } from './routes/enhanced' +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 EnhancedRoute = EnhancedRouteImport.update({ + id: '/enhanced', + path: '/enhanced', + 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 + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/components': typeof ComponentsRoute + '/css': typeof CssRoute + '/enhanced': typeof EnhancedRoute + '/imported': typeof ImportedRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/components' | '/css' | '/enhanced' | '/imported' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/components' | '/css' | '/enhanced' | '/imported' + id: '__root__' | '/' | '/components' | '/css' | '/enhanced' | '/imported' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + ComponentsRoute: typeof ComponentsRoute + CssRoute: typeof CssRoute + EnhancedRoute: typeof EnhancedRoute + ImportedRoute: typeof ImportedRoute +} + +declare module '@tanstack/solid-router' { + interface FileRoutesByPath { + '/imported': { + id: '/imported' + path: '/imported' + fullPath: '/imported' + preLoaderRoute: typeof ImportedRouteImport + parentRoute: typeof rootRouteImport + } + '/enhanced': { + id: '/enhanced' + path: '/enhanced' + fullPath: '/enhanced' + preLoaderRoute: typeof EnhancedRouteImport + 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, + EnhancedRoute: EnhancedRoute, + 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 0000000000..aa7ead6752 --- /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 0000000000..b8c8189c6b --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/__root.tsx @@ -0,0 +1,98 @@ +/// +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 0000000000..9dd6387f09 --- /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 0000000000..c1a80d492b --- /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 0000000000..05eafda03c --- /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 0000000000..98ca5e0934 --- /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 0000000000..020da5d7ad --- /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/enhanced.tsx b/e2e/solid-start/deferred-hydration/src/routes/enhanced.tsx new file mode 100644 index 0000000000..d699a644f1 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/enhanced.tsx @@ -0,0 +1,316 @@ +import { Show, createSignal, onMount, type JSX } from 'solid-js' +import { createFileRoute } from '@tanstack/solid-router' +import { Hydrate } from '@tanstack/solid-start' +import { interaction, media } from '@tanstack/solid-start/hydration' +import { EnhancedNestedWidget } from '../shared/EnhancedNestedWidget' + +type EnhancedSearch = { + dynamic?: 'interaction' +} + +export const Route = createFileRoute('/enhanced')({ + validateSearch: (search: Record): EnhancedSearch => ({ + dynamic: search.dynamic === 'interaction' ? 'interaction' : undefined, + }), + component: EnhancedHydrationPage, +}) + +type DeferredGate = { + promise: Promise + resolve: () => void +} + +const clickIntent = interaction({ events: 'click' }) +const pointerOverIntent = interaction({ events: 'pointerover' }) +const doubleClickIntent = interaction({ events: 'dblclick' }) + +function createDeferredGate(): DeferredGate { + let resolve!: () => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function InteractiveBox(props: { id: string; label: string }): JSX.Element { + const [count, setCount] = createSignal(0) + const [hydrated, setHydrated] = createSignal(false) + + onMount(() => { + setHydrated(true) + }) + + return ( + + ) +} + +function DynamicWhenExamples(): JSX.Element { + const search = Route.useSearch() + const searchDrivenHydration = () => + search().dynamic === 'interaction' ? clickIntent : doubleClickIntent + + return ( + <> +

+ Dynamic callbacks are client-only. The first boundary always hydrates on + click; the second reads typed router search state before choosing its + interaction event. +

+ clickIntent}> + + + + + + + ) +} + +function SplitPrefetchExample(): JSX.Element { + const gate = createDeferredGate() + const [waitReason, setWaitReason] = createSignal('idle') + const [preloadStatus, setPreloadStatus] = createSignal('idle') + const [queryStatus, setQueryStatus] = createSignal('idle') + + return ( + <> +

{waitReason()}

+

{preloadStatus()}

+

{queryStatus()}

+ + { + setQueryStatus(element ? 'element' : 'missing-element') + setWaitReason('waiting') + + const reason = await waitFor(pointerOverIntent) + setWaitReason(reason) + if (reason === 'abort' || signal.aborted) return + + await preload() + setPreloadStatus('done') + await gate.promise + setQueryStatus('done') + }} + > + + + + ) +} + +function FireAndForgetPrefetchExample(): JSX.Element { + const gate = createDeferredGate() + const [waitReason, setWaitReason] = createSignal('idle') + const [workStatus, setWorkStatus] = createSignal('idle') + const [queryStatus, setQueryStatus] = createSignal('idle') + + return ( + <> +

{workStatus()}

+

{waitReason()}

+

{queryStatus()}

+ + { + setWaitReason('waiting') + void waitFor(pointerOverIntent).then((reason) => { + setWaitReason(reason) + if (reason === 'abort') return + + setWorkStatus('started') + void gate.promise.then(() => { + setQueryStatus('done') + }) + }) + }} + > + + + + ) +} + +function HydrateFirstPrefetchExample(): JSX.Element { + const [reason, setReason] = createSignal('idle') + + return ( + <> +

{reason()}

+ { + setReason(await waitFor(doubleClickIntent)) + }} + > + + + + ) +} + +function RuntimeOnlyPrefetchExample(): JSX.Element { + const gate = createDeferredGate() + const [waitReason, setWaitReason] = createSignal('idle') + const [runtimeStatus, setRuntimeStatus] = createSignal('idle') + + return ( + <> +

{waitReason()}

+

{runtimeStatus()}

+ + { + setWaitReason('waiting') + const reason = await waitFor(pointerOverIntent) + setWaitReason(reason) + if (reason === 'abort') return + + await gate.promise + await preload() + setRuntimeStatus('ready') + }} + > + + + + ) +} + +function WaitForAbortExample(): JSX.Element { + const [showBoundary, setShowBoundary] = createSignal(true) + const [reason, setReason] = createSignal('idle') + + return ( + <> +

{reason()}

+ + + { + setReason('waiting') + setReason(await waitFor(pointerOverIntent)) + }} + > + + + + + ) +} + +function SignalAbortExample(): JSX.Element { + const [showBoundary, setShowBoundary] = createSignal(true) + const [status, setStatus] = createSignal('idle') + + return ( + <> +

{status()}

+ + + { + setStatus('listening') + await new Promise((resolve) => { + const onAbort = () => { + setStatus('aborted') + resolve() + } + + if (signal.aborted) { + onAbort() + return + } + + signal.addEventListener('abort', onAbort, { once: true }) + }) + }} + > + + + + + ) +} + +function NestedDynamicExamples(): JSX.Element { + return ( + <> + + clickIntent}> + + + + + + ) +} + +function EnhancedHydrationPage(): JSX.Element { + return ( +
+

Enhanced Hydrate APIs

+ + + + + + + + +
+ ) +} 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 0000000000..abfb827c91 --- /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 0000000000..61fbf7f6dc --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/routes/index.tsx @@ -0,0 +1,23 @@ +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 + +

Enhanced APIs

+ enhanced Hydrate APIs +
+ ) +} diff --git a/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx b/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx new file mode 100644 index 0000000000..e595e38da8 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/src/shared/EnhancedNestedWidget.tsx @@ -0,0 +1,33 @@ +import { createSignal, onMount } from 'solid-js' +import { Hydrate } from '@tanstack/solid-start' +import { interaction, media } from '@tanstack/solid-start/hydration' + +function CrossFileNestedButton() { + const [count, setCount] = createSignal(0) + const [hydrated, setHydrated] = createSignal(false) + + onMount(() => { + setHydrated(true) + }) + + return ( + + ) +} + +export function EnhancedNestedWidget() { + return ( + + + + + + ) +} 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 0000000000..859b741755 --- /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 0000000000..dff2357837 --- /dev/null +++ b/e2e/solid-start/deferred-hydration/tests/hydration.spec.ts @@ -0,0 +1,909 @@ +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 clickIntentAndExpectReplayedCount( + page: Page, + buttonTestId: string, + countTestId: string, + count: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + await expect(page.getByTestId(countTestId)).toHaveText(count) +} + +async function clickToHydrateThenClickAndExpectIncrement( + page: Page, + buttonTestId: string, + countTestId: string, +) { + await expectRouteToStayUnhydrated(page, buttonTestId) + await page.getByTestId(buttonTestId).click() + await expect(page.getByTestId(buttonTestId)).toHaveAttribute( + 'data-hydrated', + 'true', + ) + const previousCount = Number( + await page.getByTestId(countTestId).textContent(), + ) + await page.getByTestId(buttonTestId).click() + await expect + .poll(async () => Number(await page.getByTestId(countTestId).textContent())) + .toBe(previousCount + 1) +} + +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 trackBoundaryStability(page: Page, buttonTestId: string) { + await page.getByTestId(buttonTestId).evaluate((element, testId) => { + const marker = element.closest('[data-ts-hydrate-id]') + + if (!(marker instanceof HTMLElement)) { + throw new Error('Expected Hydrate marker to exist') + } + + const win = window as typeof window & { + __hydrateBoundaryVisualGapCount?: number + __hydrateBoundaryMaxScrollDelta?: number + __hydrateBoundaryStabilityDone?: boolean + } + const hasBoundaryButton = () => + Array.from(marker.querySelectorAll('[data-testid]')).some( + (child) => child.getAttribute('data-testid') === testId, + ) + const hasHydratedBoundaryButton = () => + Array.from(marker.querySelectorAll('[data-testid]')).some( + (child) => + child.getAttribute('data-testid') === testId && + child.getAttribute('data-hydrated') === 'true', + ) + const initialScrollY = window.scrollY + + win.__hydrateBoundaryVisualGapCount = 0 + win.__hydrateBoundaryMaxScrollDelta = 0 + win.__hydrateBoundaryStabilityDone = false + + let frameCount = 0 + let hydratedFrameCount = 0 + let observer: MutationObserver | undefined + const recordScroll = () => { + win.__hydrateBoundaryMaxScrollDelta = Math.max( + win.__hydrateBoundaryMaxScrollDelta ?? 0, + Math.abs(window.scrollY - initialScrollY), + ) + + if (hasHydratedBoundaryButton()) hydratedFrameCount++ + + frameCount++ + if (hydratedFrameCount >= 10 || frameCount >= 300) { + observer?.disconnect() + win.__hydrateBoundaryStabilityDone = true + return + } + + requestAnimationFrame(recordScroll) + } + + observer = new MutationObserver(() => { + if (!hasBoundaryButton()) { + win.__hydrateBoundaryVisualGapCount!++ + } + }) + observer.observe(marker, { childList: true }) + + requestAnimationFrame(recordScroll) + }, buttonTestId) +} + +async function expectBoundaryStable(page: Page) { + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __hydrateBoundaryStabilityDone?: boolean + } + ).__hydrateBoundaryStabilityDone ?? false, + ), + ) + .toBe(true) + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __hydrateBoundaryVisualGapCount?: number + } + ).__hydrateBoundaryVisualGapCount ?? 0, + ), + ) + .toBe(0) + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __hydrateBoundaryMaxScrollDelta?: number + } + ).__hydrateBoundaryMaxScrollDelta ?? 0, + ), + ) + .toBeLessThanOrEqual(1) +} + +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) +} + +async function gotoEnhanced(page: Page, search = '') { + await page.goto(`/enhanced${search}`) + await expectClientRouterReady(page) +} + +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 scrollToBoundary(page, 'component-click-replay-button') + await expectRouteToStayUnhydrated(page, 'component-click-replay-button') + await trackBoundaryStability(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 expectBoundaryStable(page) + 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 trackBoundaryStability(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 expectBoundaryStable(page) + await expect(page.getByTestId('component-click-replay-count')).toHaveText( + '1', + ) + }) + + test('keeps bottom scroll stable when a condition boundary hydrates', async ({ + page, + }) => { + await page.goto('/components') + await expectClientRouterReady(page) + await expectRouteToStayUnhydrated(page, 'component-condition-button') + + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + await page.waitForTimeout(100) + await expect( + page.getByTestId('component-enable-condition'), + ).toBeInViewport() + + await trackBoundaryStability(page, 'component-condition-button') + await page.getByTestId('component-enable-condition').click() + await expect( + page.getByTestId('component-condition-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expectBoundaryStable(page) + await clickAndExpectCount( + page, + 'component-condition-button', + 'component-condition-count', + '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('enhanced Hydrate API combinations', () => { + test('server renders dynamic markers without evaluating client-only callbacks or prefetch functions', async ({ + request, + }) => { + const response = await request.get('/enhanced?dynamic=interaction') + const html = await response.text() + + expect(response.ok()).toBe(true) + htmlContainsText(html, 'Enhanced Hydrate APIs') + htmlContainsText(html, 'conditional dynamic') + expect(html).toContain('data-ts-hydrate-when="dynamic"') + expect(html).not.toContain('missing-element') + }) + + test('dynamic when functions hydrate and replay interaction events', async ({ + page, + }) => { + await gotoEnhanced(page, '?dynamic=interaction') + await expect(page.getByTestId('enhanced-heading')).toHaveText( + 'Enhanced Hydrate APIs', + ) + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-dynamic-interaction-button', + 'enhanced-dynamic-interaction-count', + '1', + ) + + await expectRouteToStayUnhydrated( + page, + 'enhanced-dynamic-conditional-button', + ) + await page.getByTestId('enhanced-dynamic-conditional-button').hover() + await expectRouteToStayUnhydrated( + page, + 'enhanced-dynamic-conditional-button', + ) + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-dynamic-conditional-button', + 'enhanced-dynamic-conditional-count', + '1', + ) + }) + + test('procedural prefetch can block hydration, preload the split chunk, and prepare query-like work', async ({ + page, + request, + }) => { + await gotoEnhanced(page) + await expect( + resourceContentsContain( + page, + request, + 'enhanced-procedural-split-child', + isClientJavaScriptResource, + ), + ).resolves.toBe(false) + + await expectRouteToStayUnhydrated(page, 'enhanced-procedural-split-button') + await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-procedural-split-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-split-query')).toHaveText('element') + await expect(page.getByTestId('enhanced-split-wait-reason')).toHaveText( + 'prefetch', + ) + await expect(page.getByTestId('enhanced-split-preload')).toHaveText('done') + await expect + .poll(() => + resourceContentsContain( + page, + request, + 'enhanced-procedural-split-child', + isClientJavaScriptResource, + ), + ) + .toBe(true) + + await page.getByTestId('enhanced-procedural-split-button').click() + await expect( + page.getByTestId('enhanced-procedural-split-button'), + ).toHaveAttribute('data-hydrated', 'false') + await expect( + page.getByTestId('enhanced-procedural-split-count'), + ).toHaveText('0') + await page.getByTestId('enhanced-release-split-prefetch').click() + await expect( + page.getByTestId('enhanced-procedural-split-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect( + page.getByTestId('enhanced-procedural-split-count'), + ).toHaveText('1') + await expect(page.getByTestId('enhanced-split-query')).toHaveText('done') + }) + + test('function prefetch supports fire-and-forget work and waitFor hydrate-first resolution', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-fire-and-forget-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-fire-wait-reason')).toHaveText( + 'prefetch', + ) + await expect(page.getByTestId('enhanced-fire-status')).toHaveText('started') + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-fire-and-forget-button', + 'enhanced-fire-and-forget-count', + '1', + ) + await expect(page.getByTestId('enhanced-fire-query')).toHaveText('idle') + await page.getByTestId('enhanced-release-fire-prefetch').click() + await expect(page.getByTestId('enhanced-fire-query')).toHaveText('done') + + await clickIntentAndExpectReplayedCount( + page, + 'enhanced-hydrate-first-button', + 'enhanced-hydrate-first-count', + '1', + ) + await expect(page.getByTestId('enhanced-hydrate-first-reason')).toHaveText( + 'hydrate', + ) + }) + + test('split=false procedural prefetch blocks hydration without requiring a child preload chunk', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expectRouteToStayUnhydrated(page, 'enhanced-runtime-only-button') + await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText( + 'waiting', + ) + await dispatchHydrationIntent( + page, + 'enhanced-runtime-only-button', + 'pointerover', + ) + await expect(page.getByTestId('enhanced-runtime-wait-reason')).toHaveText( + 'prefetch', + ) + await page.getByTestId('enhanced-runtime-only-button').click() + await expect( + page.getByTestId('enhanced-runtime-only-button'), + ).toHaveAttribute('data-hydrated', 'false') + await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText( + '0', + ) + await page.getByTestId('enhanced-release-runtime-prefetch').click() + await expect(page.getByTestId('enhanced-runtime-status')).toHaveText( + 'ready', + ) + await expect( + page.getByTestId('enhanced-runtime-only-button'), + ).toHaveAttribute('data-hydrated', 'true') + await expect(page.getByTestId('enhanced-runtime-only-count')).toHaveText( + '1', + ) + }) + + test('procedural prefetch aborts waiters and signals when boundaries unmount', async ({ + page, + }) => { + await gotoEnhanced(page) + + await expectRouteToStayUnhydrated(page, 'enhanced-wait-abort-button') + await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText( + 'waiting', + ) + await page.getByTestId('enhanced-hide-wait-abort').click() + await expect(page.getByTestId('enhanced-wait-abort-reason')).toHaveText( + 'abort', + ) + + await expect(page.getByTestId('enhanced-abort-status')).toHaveText( + 'listening', + ) + await page.getByTestId('enhanced-hide-abort').click() + await expect(page.getByTestId('enhanced-abort-status')).toHaveText( + 'aborted', + ) + }) + + test('nested dynamic interaction boundaries delegate through outer boundaries', async ({ + page, + }) => { + await gotoEnhanced(page) + + await clickToHydrateThenClickAndExpectIncrement( + page, + 'enhanced-dynamic-nested-button', + 'enhanced-dynamic-nested-count', + ) + await clickToHydrateThenClickAndExpectIncrement( + page, + 'enhanced-cross-file-nested-button', + 'enhanced-cross-file-nested-count', + ) + }) +}) + +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 0000000000..76cf3401fa --- /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 0000000000..70f388638a --- /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 48de353b2e..6637c7542b 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, useLayoutEffect } from './utils' export { FileRoute, diff --git a/packages/react-start-client/package.json b/packages/react-start-client/package.json index a9f8bcf0a0..0b0c192d8d 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 0000000000..e92042a0d7 --- /dev/null +++ b/packages/react-start-client/src/GenericHydrate.tsx @@ -0,0 +1,436 @@ +'use client' + +import * as React from 'react' + +import { reactUse, useHydrated, useLayoutEffect } 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, + runHydrationStrategyCleanup, + saveFallbackHtml, + waitForHydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration/runtime' +import { listenForDelegatedHydrationIntent } from '@tanstack/start-client-core/hydration' +import type { + HydrationRuntimeContext, + HydrationStrategy, + HydrationWhen, +} 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 } +type PrefetchController = { + abortController: AbortController + hydrationRequested: boolean + hydrationListeners: Set<() => void> + hydrationResolvePending: boolean + started: boolean + promise?: Promise + cleanup?: () => void +} + +const dynamicType = 'dynamic' +const dynamicHydrateStrategy = { + _t: dynamicType, + _d: () => true, +} satisfies HydrationStrategy + +function shouldDeferHydration(strategy: HydrationStrategy) { + return strategy._d ? strategy._d() : strategy._t !== 'load' +} + +function useLatest(value: T) { + const ref = React.useRef(value) + ref.current = value + return ref +} + +function useHydrationGate(props: InternalHydrateProps) { + const hydrated = useHydrated() + const reactId = React.useId() + const id = props.h ? `${props.h}${reactId}` : reactId + const when = props.when + const isDynamicHydrate = typeof when === 'function' + const dynamicHydrateStrategyRef = React.useRef( + undefined, + ) + if (isDynamicHydrate) { + dynamicHydrateStrategyRef.current ??= + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') + ? dynamicHydrateStrategy + : when() + } + const hydrateStrategy = isDynamicHydrate + ? dynamicHydrateStrategyRef.current! + : when + const markerHydrateType: HydrationWhen = isDynamicHydrate + ? dynamicType + : hydrateStrategy._t! + const [prefetchError, setPrefetchError] = React.useState() + const latestRef = useLatest({ + prefetch: props.prefetch, + preload: props.p, + }) + 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) + const prefetchControllerRef = React.useRef( + undefined, + ) + + prefetchControllerRef.current ??= { + abortController: new AbortController(), + hydrationRequested: false, + hydrationListeners: new Set<() => void>(), + hydrationResolvePending: false, + started: false, + } + + shouldPreserveServerHTMLRef.current ??= + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') || !hydrated + shouldDeferInitialHydrationRef.current ??= + !hydrated && shouldDeferHydration(hydrateStrategy) + + if (!gateRef.current) { + gateRef.current = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') + ? createResolvedGate(id, hydrateStrategy._t!) + : getOrCreateGate(id, hydrateStrategy._t!) + } + + gateRef.current.when = hydrateStrategy._t! + + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + !(isServer ?? typeof window === 'undefined') && + hydrateStrategy._t !== 'never' && + (!shouldDeferInitialHydrationRef.current || + !shouldDeferHydration(hydrateStrategy)) + ) { + gateRef.current.resolve() + } + + const markerRef = React.useCallback( + (element: HTMLDivElement | null) => { + markerElementRef.current = element + if (element) { + if ( + hydrateStrategy._t === 'never' && + !shouldPreserveServerHTMLRef.current + ) { + element.replaceChildren() + } + saveFallbackHtml(id, element) + } + }, + [hydrateStrategy._t, id], + ) + + React.useEffect(() => { + const gate = gateRef.current! + return () => { + const controller = prefetchControllerRef.current + controller?.abortController.abort() + controller?.cleanup?.() + controller?.hydrationListeners.clear() + releaseGate(gate) + } + }, []) + + React.useEffect(() => { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') || + !latestRef.current.prefetch + ) { + return + } + + const controller = prefetchControllerRef.current! + if (controller.started) return + controller.started = true + + const onHydrate = (listener: () => void) => { + if (controller.hydrationRequested) { + listener() + return () => {} + } + + controller.hydrationListeners.add(listener) + return () => { + controller.hydrationListeners.delete(listener) + } + } + + const preload = () => latestRef.current.preload?.() ?? Promise.resolve() + const prefetchInput = latestRef.current.prefetch + + if (typeof prefetchInput === 'function') { + const promise = Promise.resolve() + .then(() => + prefetchInput({ + element: markerElementRef.current, + signal: controller.abortController.signal, + preload, + waitFor: (strategy) => + waitForHydrationPrefetchStrategy(strategy, { + element: markerElementRef.current, + signal: controller.abortController.signal, + onHydrate, + }), + }), + ) + .then(() => undefined) + + controller.promise = promise + promise.catch((error) => { + if (!controller.abortController.signal.aborted) { + setPrefetchError(error) + } + }) + return + } + + if (!latestRef.current.preload) return + + const prefetch = () => { + if (didPrefetchRef.current) return + didPrefetchRef.current = true + void preload() + } + + controller.cleanup = runHydrationStrategyCleanup( + prefetchInput._s?.({ + element: markerElementRef.current, + prefetch, + }), + ) + }, [hydrateStrategy, latestRef]) + + useLayoutEffect(() => { + const gate = gateRef.current! + if ( + !shouldDeferInitialHydrationRef.current || + hydrateStrategy._t === 'never' + ) { + return + } + + if (gate.resolved) { + return + } + + const cleanups: Array<() => void> = [] + let removeResolveListener = () => {} + let disposed = false + const resolveGate = gate.resolve + + const cleanup = () => { + if (disposed) return + disposed = true + if (gate.resolve === requestHydration) { + gate.resolve = resolveGate + } + removeResolveListener() + cleanups.forEach((fn) => fn()) + } + + const addCleanup = (fn: void | (() => void)) => { + if (!fn) return + if (disposed || gate.resolved) { + fn() + return + } + cleanups.push(fn) + } + + const requestHydration = () => { + const controller = prefetchControllerRef.current! + if (!controller.hydrationRequested) { + controller.hydrationRequested = true + controller.hydrationListeners.forEach((listener) => listener()) + controller.hydrationListeners.clear() + } + + if (!controller.promise) { + resolveGate() + return + } + if (controller.hydrationResolvePending) return + controller.hydrationResolvePending = true + + controller.promise.then( + () => resolveGate(), + (error) => { + if (!controller.abortController.signal.aborted) { + setPrefetchError(error) + } + }, + ) + } + + gate.resolve = requestHydration + removeResolveListener = onGateResolve(gate, cleanup) + + const context: HydrationRuntimeContext = { + element: markerElementRef.current, + gate, + } + addCleanup(runHydrationStrategyCleanup(hydrateStrategy._s?.(context))) + + if (hydrateStrategy._t !== 'interaction') { + addCleanup( + runHydrationStrategyCleanup( + markerElementRef.current + ? listenForDelegatedHydrationIntent( + markerElementRef.current, + context, + ) + : undefined, + ), + ) + } + + return cleanup + }, [hydrateStrategy, latestRef]) + + return { + gate: gateRef.current, + markerRef, + markerElementRef, + hydrateStrategy, + markerHydrateType, + prefetchError, + shouldPreserveServerHTML: shouldPreserveServerHTMLRef.current, + } +} + +function HydrationGate(props: { gate: Gate; children: React.ReactNode }) { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + isServer ?? + typeof window === 'undefined' + ) { + return props.children as React.JSX.Element + } + + if (props.gate.resolved) { + return props.children as React.JSX.Element + } + + if (!reactUse) { + throw props.gate.promise + } + + reactUse(props.gate.promise) + + return props.children as React.JSX.Element +} + +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 +} + +export function GenericHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const { + gate, + hydrateStrategy, + markerHydrateType, + markerElementRef, + markerRef, + prefetchError, + shouldPreserveServerHTML, + } = useHydrationGate(internalProps) + if (prefetchError) throw prefetchError + + const fallback = shouldPreserveServerHTML + ? (() => { + const html = getFallbackHtml(gate.id) + return html ? ( +
+ ) : null + })() + : (props.fallback ?? null) + const markerAttributes = + markerHydrateType === dynamicType ? undefined : hydrateStrategy._a?.() + + const hydrateType = hydrateStrategy._t! + + if (hydrateType === 'never' && !shouldPreserveServerHTML) { + return ( +
+ {props.fallback ?? null} +
+ ) + } + + return ( +
+ + + { + markerElementRef.current?.removeAttribute(hydrateWhenAttribute) + hydrateStrategy._o?.(id) + }} + > + {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 0000000000..019730f3be --- /dev/null +++ b/packages/react-start-client/src/Hydrate.tsx @@ -0,0 +1,107 @@ +'use client' + +import * as React from 'react' + +import { isServer } from '@tanstack/router-core/isServer' +import type { + HydrationStrategy as CoreHydrationStrategy, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' + +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' + +export type ReactHydrationStrategy< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = CoreHydrationStrategy & { + _h: (this: ReactHydrationStrategy, props: HydrateProps) => React.JSX.Element +} + +export type HydrationStrategy< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = ReactHydrationStrategy + +export type HydrateWhen = + | ReactHydrationStrategy + | (() => ReactHydrationStrategy) + +type HydrateCommonOptions = { + when: HydrateWhen + fallback?: React.ReactNode + onHydrated?: () => void +} + +export type HydrateOptions = + | (HydrateCommonOptions & { + prefetch?: never + split?: boolean + }) + | (HydrateCommonOptions & { + prefetch: HydrationPrefetchStrategy + split?: true + }) + | (HydrateCommonOptions & { + prefetch: HydrationPrefetchFunction + split?: boolean + }) + +export type HydrateProps = HydrateOptions & { + children: React.ReactNode +} + +export type InternalHydrateProps = HydrateProps & { + h?: string + p?: () => Promise +} + +const dynamicType = 'dynamic' +const hydrateIdAttribute = 'data-ts-hydrate-id' +const hydrateWhenAttribute = 'data-ts-hydrate-when' + +/* @__NO_SIDE_EFFECTS__ */ +function ServerDynamicHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const reactId = React.useId() + const id = internalProps.h ? `${internalProps.h}${reactId}` : reactId + + return ( +
+ + {props.children} + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function Hydrate(props: HydrateProps): React.JSX.Element { + if (typeof props.when === 'function') { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + isServer ?? + typeof window === 'undefined' + ) { + return + } + + return props.when()._h(props) + } + + return props.when._h(props) +} diff --git a/packages/react-start-client/src/hydration.ts b/packages/react-start-client/src/hydration.ts new file mode 100644 index 0000000000..fad458175c --- /dev/null +++ b/packages/react-start-client/src/hydration.ts @@ -0,0 +1,22 @@ +'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, + IdleHydrationOptions, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchWhen, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationStrategyTypes, + 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 0000000000..5ee6d3c3d6 --- /dev/null +++ b/packages/react-start-client/src/hydration/generic.ts @@ -0,0 +1,43 @@ +'use client' + +import { + condition as coreCondition, + interaction as coreInteraction, + media as coreMedia, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationCondition, + HydrationInteractionEvents, + HydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration' +import type { ReactHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function media( + query: string, +): ReactHydrationStrategy<'media', true> & HydrationPrefetchStrategy<'media'> { + return /* @__PURE__ */ withHydrationRenderer(coreMedia(query), GenericHydrate) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function condition( + condition: HydrationCondition, +): ReactHydrationStrategy<'condition', false> { + return /* @__PURE__ */ withHydrationRenderer( + coreCondition(condition), + GenericHydrate, + ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction(options?: { + events?: HydrationInteractionEvents +}): ReactHydrationStrategy<'interaction', true> & + HydrationPrefetchStrategy<'interaction'> { + return /* @__PURE__ */ withHydrationRenderer( + coreInteraction(options), + GenericHydrate, + ) +} 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 0000000000..58c7f265eb --- /dev/null +++ b/packages/react-start-client/src/hydration/idle.ts @@ -0,0 +1,22 @@ +'use client' + +import { + idle as coreIdle, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationPrefetchStrategy, + IdleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { ReactHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function idle( + options: IdleHydrationOptions = {}, +): ReactHydrationStrategy<'idle', true> & HydrationPrefetchStrategy<'idle'> { + return /* @__PURE__ */ withHydrationRenderer( + coreIdle(options), + GenericHydrate, + ) +} 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 0000000000..006f4e6952 --- /dev/null +++ b/packages/react-start-client/src/hydration/load.tsx @@ -0,0 +1,49 @@ +'use client' + +import * as React from 'react' + +import { + load as coreLoad, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration' +import type { HydrateProps, ReactHydrationStrategy } from '../Hydrate' + +function HydratedBoundary(props: { + onHydrated?: () => void + children: React.ReactNode +}) { + const { onHydrated, children } = props + const didHydrateRef = React.useRef(false) + + React.useEffect(() => { + if (didHydrateRef.current) return + didHydrateRef.current = true + onHydrated?.() + }, [onHydrated]) + + return children as React.JSX.Element +} + +export function LoadHydrate(props: HydrateProps): React.JSX.Element { + return ( +
+ + + {props.children} + + +
+ ) +} + +const loadStrategy = /* @__PURE__ */ withHydrationRenderer( + coreLoad(), + LoadHydrate, +) as ReactHydrationStrategy<'load', true> & HydrationPrefetchStrategy<'load'> + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): ReactHydrationStrategy<'load', true> & + HydrationPrefetchStrategy<'load'> { + return loadStrategy +} 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 0000000000..250a8ed1ae --- /dev/null +++ b/packages/react-start-client/src/hydration/never.tsx @@ -0,0 +1,97 @@ +'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, + withHydrationRenderer, +} 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 ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + isServer ?? + typeof window === 'undefined' + ) { + return props.children as React.JSX.Element + } + + if (!reactUse) { + throw neverPromise + } + + reactUse(neverPromise) + + return props.children as React.JSX.Element +} + +export function NeverHydrate(props: HydrateProps): React.JSX.Element { + const internalProps = props as InternalHydrateProps + const hydrated = useHydrated() + const reactId = React.useId() + const id = internalProps.h ? `${internalProps.h}${reactId}` : reactId + const shouldPreserveServerHTMLRef = React.useRef( + undefined, + ) + shouldPreserveServerHTMLRef.current ??= + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? 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, + } + const fallback = (() => { + const html = getFallbackHtml(id) + return html ? ( +
+ ) : ( + (props.fallback ?? null) + ) + })() + + return ( +
+ + {props.children} + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function never(): ReactHydrationStrategy<'never', false> { + return /* @__PURE__ */ withHydrationRenderer(coreNever(), 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 0000000000..e274285cb8 --- /dev/null +++ b/packages/react-start-client/src/hydration/visible.tsx @@ -0,0 +1,139 @@ +'use client' + +import * as React from 'react' + +import { reactUse } from '@tanstack/react-router' +import { isServer } from '@tanstack/router-core/isServer' +import type { + HydrationPrefetchStrategy, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { + HydrateProps, + InternalHydrateProps, + ReactHydrationStrategy, +} from '../Hydrate' + +type VisibleGate = { + p: Promise + r: boolean + s: () => void +} + +/* @__NO_SIDE_EFFECTS__ */ +function HydrationBoundary(props: { + g: VisibleGate + o?: () => void + children?: React.ReactNode +}) { + const { g, o } = props + + if (!g.r) { + if (!reactUse) { + throw g.p + } + + reactUse(g.p) + } + + React.useEffect(() => { + o?.() + }, [o]) + + return props.children as React.JSX.Element +} + +/* @__NO_SIDE_EFFECTS__ */ +export function VisibleHydrate( + this: ReactHydrationStrategy, + props: HydrateProps, +): React.JSX.Element { + const strategy = this as ReactHydrationStrategy<'visible', true> + const prefetchStrategy = props.prefetch + const preload = (props as InternalHydrateProps).p + const markerRef = React.useRef(null) + const [gate] = React.useState(() => { + let resolvePromise!: () => void + const nextGate: VisibleGate = { + p: new Promise((resolve) => { + resolvePromise = resolve + }), + r: false, + s: () => { + nextGate.r = true + resolvePromise() + }, + } + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + isServer ?? + typeof window === 'undefined' + ) { + nextGate.s() + } + + return nextGate + }) + + React.useEffect(() => { + if (!preload || typeof prefetchStrategy === 'function') { + return + } + + return prefetchStrategy?._s?.({ + element: markerRef.current, + prefetch: preload, + }) + }, [prefetchStrategy, preload]) + + React.useEffect(() => { + if (gate.r) return + + return strategy._s?.({ + element: markerRef.current, + gate: gate as never, + }) + }, [gate, strategy]) + + return ( +
+ + + {props.children} + + +
+ ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options?: VisibleHydrationOptions, +): ReactHydrationStrategy<'visible', true> & + HydrationPrefetchStrategy<'visible'> { + const rootMargin = options?.rootMargin ?? '600px' + const threshold = options?.threshold ?? 0 + + return { + _s: ({ element, gate, prefetch }) => { + const callback = prefetch || (gate as never as VisibleGate).s + + if (!element) { + callback() + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]!.isIntersecting) return + observer.disconnect() + callback() + }, + { rootMargin, threshold }, + ) + observer.observe(element) + return () => observer.disconnect() + }, + _h: VisibleHydrate, + } +} diff --git a/packages/react-start-client/src/index.tsx b/packages/react-start-client/src/index.tsx index aa73990a57..669b1e0e3f 100644 --- a/packages/react-start-client/src/index.tsx +++ b/packages/react-start-client/src/index.tsx @@ -1,2 +1,18 @@ +'use client' + export { StartClient } from './StartClient' export { hydrateStart } from './hydrateStart' +export { Hydrate } from './Hydrate' +export type { + HydrateOptions, + HydrateProps, + HydrateWhen, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationStrategy, + HydrationWhen, +} from './Hydrate' 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 0000000000..2e3726edfa --- /dev/null +++ b/packages/react-start-client/src/tests/Hydrate.test-d.tsx @@ -0,0 +1,147 @@ +import { expectTypeOf, test } from 'vitest' +import { visible } from '../hydration' +import { Hydrate } from '../Hydrate' +import type { + HydrateOptions, + HydrateProps, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationStrategy, +} from '../Hydrate' +import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration' +import type { ReactNode } from 'react' + +type CommonHydrateProps = { + fallback?: ReactNode + onHydrated?: () => void + children: ReactNode +} + +type SplitHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch?: never + split?: boolean +} + +type PrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch: HydrationPrefetchStrategy + split?: true +} + +type FunctionPrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch: HydrationPrefetchFunction + split?: boolean +} + +test('Hydrate component accepts the public HydrateProps type', () => { + expectTypeOf(Hydrate).toBeFunction() + expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf() +}) + +test('HydrateOptions supports reusable spread props', () => { + const belowFoldProps = { + when: () => visible({ rootMargin: '800px' }), + } satisfies HydrateOptions + + expectTypeOf(belowFoldProps).toMatchTypeOf() + + const withFunctionPrefetch = { + when: visible(), + split: false, + prefetch: (ctx) => { + expectTypeOf(ctx.element).toEqualTypeOf() + expectTypeOf(ctx.signal).toEqualTypeOf() + expectTypeOf(ctx.preload).returns.toEqualTypeOf>() + expectTypeOf(ctx.waitFor).returns.toEqualTypeOf< + Promise<'prefetch' | 'hydrate' | 'abort'> + >() + }, + } satisfies HydrateOptions + + expectTypeOf(withFunctionPrefetch).toMatchTypeOf() +}) + +test('Hydrate props are exact for strategy and prefetch forms', () => { + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() +}) + +test('Hydrate requires a strategy', () => { + expectTypeOf<{ + when: HydrationStrategy + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + when: () => HydrationStrategy + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + children: ReactNode + }>().not.toMatchTypeOf() + + expectTypeOf<{ + when: () => true + children: ReactNode + }>().not.toMatchTypeOf() + + expectTypeOf<{ + when: false + 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() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchFunction + split: false + children: ReactNode + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchFunction + children: ReactNode + }>().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 0000000000..0d0ce1faad --- /dev/null +++ b/packages/react-start-client/src/tests/Hydrate.test.tsx @@ -0,0 +1,676 @@ +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 { condition, idle, interaction, load, never } from '../hydration' +import type { HydrateProps, HydrationPrefetchStrategy } from '../Hydrate' + +const InternalHydrate = Hydrate as React.ComponentType< + HydrateProps & { p?: () => Promise; h?: 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) + } + }) + + it('does not evaluate dynamic when callbacks on the server', async () => { + const when = vi.fn(() => interaction({ events: 'dblclick' })) + + vi.stubGlobal('window', undefined) + const html = renderToString( + + + , + ) + vi.unstubAllGlobals() + + expect(when).not.toHaveBeenCalled() + expect(html).toContain('data-ts-hydrate-when="dynamic"') + + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = html + + let root!: ReturnType + try { + await act(async () => { + root = hydrateRoot( + container, + + + , + ) + await Promise.resolve() + }) + + expect(when).toHaveBeenCalled() + 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('replays an interaction captured before the Hydrate component hydrates', async () => { + const when = () => interaction({ events: 'click' }) + + vi.stubGlobal('window', undefined) + const html = renderToString( + + + , + ) + vi.unstubAllGlobals() + + const container = document.createElement('div') + document.body.append(container) + container.innerHTML = html + + const button = container.querySelector('[data-testid="child"]') + if (!button) { + throw new Error('Expected server-rendered child button') + } + + button.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ) + + let root!: ReturnType + try { + await act(async () => { + root = hydrateRoot( + container, + + + , + ) + await Promise.resolve() + }) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + await waitFor(() => + expect(screen.getByTestId('child').textContent).toBe('1'), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('blocks hydration on awaited procedural prefetch work', async () => { + const preload = vi.fn(() => Promise.resolve()) + let resolvePrefetch!: () => void + const prefetchBlocker = new Promise((resolve) => { + resolvePrefetch = resolve + }) + const waitReasons: Array = [] + const neverPrefetches = { + _t: 'idle', + _s: () => () => {}, + } as HydrationPrefetchStrategy<'idle'> + + const { container, root } = await hydrateFromServer( + { + waitReasons.push(await waitFor(neverPrefetches)) + await preload() + await prefetchBlocker + }} + p={preload} + > + + , + ) + + try { + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => expect(waitReasons).toEqual(['hydrate'])) + expect(preload).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + + await act(async () => { + resolvePrefetch() + await prefetchBlocker + await Promise.resolve() + }) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('hydrates when a condition strategy changes after the initial render', async () => { + function ConditionHarness() { + const [ready, setReady] = React.useState(false) + + return ( + <> + + + + + + ) + } + + const { container, root } = await hydrateFromServer() + + try { + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + + await act(async () => { + fireEvent.click(screen.getByTestId('ready')) + await Promise.resolve() + }) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('does not block hydration on fire-and-forget procedural prefetch work', async () => { + let resolvePrefetch!: () => void + const prefetchBlocker = new Promise((resolve) => { + resolvePrefetch = resolve + }) + + const { container, root } = await hydrateFromServer( + { + void prefetchBlocker + }} + > + + , + ) + + try { + await fireIntent(() => + getMarker().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + + await act(async () => { + resolvePrefetch() + await prefetchBlocker + }) + } finally { + await unmountHydratedRoot(root, container) + } + }) + + it('aborts procedural prefetch when the boundary unmounts', async () => { + const signals: Array = [] + + const { container, root } = await hydrateFromServer( + { + signals.push(signal) + return new Promise(() => {}) + }} + > + + , + ) + + expect(signals).toHaveLength(1) + expect(signals[0]!.aborted).toBe(false) + + await unmountHydratedRoot(root, container) + expect(signals[0]!.aborted).toBe(true) + }) + + it('delegates nested interaction boundaries at runtime', async () => { + const { container, root } = await hydrateFromServer( + + + + + , + ) + + try { + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'false', + ) + + await fireIntent(() => { + fireEvent.click(screen.getByTestId('child')) + }) + + await waitFor(() => + expect(screen.getByTestId('child').getAttribute('data-hydrated')).toBe( + 'true', + ), + ) + } finally { + await unmountHydratedRoot(root, container) + } + }) +}) diff --git a/packages/react-start-client/vite.config.ts b/packages/react-start-client/vite.config.ts index 32119eee3a..40e07174ac 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 fea88eb30c..907392e1ac 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 0000000000..e86169e71d --- /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 8b51b6c783..65b116ec2e 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 } 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 0995b1f9b4..652e4ef842 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 ded15fff6d..e737fa50f8 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 2bca7009d9..c1e467f2fb 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 d59b4fa9af..32eade99af 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 4b2363e361..ebf1f4aa30 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 f764bfe8bb..d1d36bbe37 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 e291b05fe9..f197f4890a 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 a56e097d3a..e390c046a2 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 5a68f9453f..ae64e2778b 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 0000000000..78d6cd9b86 --- /dev/null +++ b/packages/router-utils/src/compiler-helpers.ts @@ -0,0 +1,843 @@ +import * as t from '@babel/types' + +type IdentifierScopeFrame = { + kind: 'program' | 'function' | 'block' + bindings: Set +} +type IdentifierScopeStack = Array + +export type ModuleInfoBinding = + | { + type: 'import' + source: string + importedName: string + } + | { + type: 'var' + init: t.Expression | null + } + +export interface ExtractedModuleInfo { + bindings: Map + exports: Map + reExportAllSources: Array +} + +function getModuleExportName(node: t.Identifier | t.StringLiteral) { + return t.isIdentifier(node) ? node.name : node.value +} + +function addVariableDeclarationModuleInfo( + declaration: t.VariableDeclaration, + bindings: Map, + exportMap?: Map, +) { + for (const declarator of declaration.declarations) { + for (const name of collectIdentifiersFromPattern(declarator.id)) { + bindings.set(name, { + type: 'var', + init: declarator.init ?? null, + }) + exportMap?.set(name, name) + } + } +} + +function addDeclarationModuleInfo( + declaration: t.Declaration, + bindings: Map, + exportMap?: Map, +) { + if (t.isVariableDeclaration(declaration)) { + addVariableDeclarationModuleInfo(declaration, bindings, exportMap) + return + } + + if ( + (t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration)) && + declaration.id + ) { + bindings.set(declaration.id.name, { + type: 'var', + init: null, + }) + exportMap?.set(declaration.id.name, declaration.id.name) + } +} + +function hasIdentifierBinding(scopes: IdentifierScopeStack, name: string) { + for (let i = scopes.length - 1; i >= 0; i--) { + if (scopes[i]!.bindings.has(name)) { + return true + } + } + return false +} + +function currentIdentifierScope(scopes: IdentifierScopeStack) { + return scopes[scopes.length - 1]! +} + +function nearestFunctionIdentifierScope(scopes: IdentifierScopeStack) { + for (let i = scopes.length - 1; i >= 0; i--) { + const scope = scopes[i]! + if (scope.kind === 'function' || scope.kind === 'program') { + return scope + } + } + return currentIdentifierScope(scopes) +} + +function addIdentifierPatternBindings( + pattern: t.LVal | t.Node | null | undefined, + scope: IdentifierScopeFrame, +) { + for (const name of collectIdentifiersFromPattern(pattern)) { + scope.bindings.add(name) + } +} + +function addIdentifierDeclarationBindings( + declaration: t.Node, + scopes: IdentifierScopeStack, +) { + if (t.isVariableDeclaration(declaration)) { + const scope = + declaration.kind === 'var' + ? nearestFunctionIdentifierScope(scopes) + : currentIdentifierScope(scopes) + for (const declarator of declaration.declarations) { + addIdentifierPatternBindings(declarator.id, scope) + } + return + } + + if ( + (t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration) || + t.isTSTypeAliasDeclaration(declaration) || + t.isTSInterfaceDeclaration(declaration) || + t.isTSEnumDeclaration(declaration)) && + declaration.id + ) { + currentIdentifierScope(scopes).bindings.add(declaration.id.name) + } +} + +function addIdentifierImportBindings( + node: t.ImportDeclaration, + scope: IdentifierScopeFrame, +) { + for (const specifier of node.specifiers) { + scope.bindings.add(specifier.local.name) + } +} + +function createNestedIdentifierScope( + kind: IdentifierScopeFrame['kind'], + scopes: IdentifierScopeStack, +): IdentifierScopeStack { + return [...scopes, { kind, bindings: new Set() }] +} + +function addIdentifierBlockBindings( + body: Array, + scopes: IdentifierScopeStack, +) { + for (const statement of body) { + if (t.isImportDeclaration(statement)) { + addIdentifierImportBindings(statement, currentIdentifierScope(scopes)) + } else if (t.isExportNamedDeclaration(statement) && statement.declaration) { + addIdentifierDeclarationBindings(statement.declaration, scopes) + } else { + addIdentifierDeclarationBindings(statement, scopes) + } + } +} + +function walkIdentifierChildren( + current: t.Node, + parent: t.Node | undefined, + scopes: IdentifierScopeStack, + ids: Set, +) { + 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') { + walkIdentifierNode(item, current, parent, key, scopes, ids) + } + } + } else if (child && typeof child.type === 'string') { + walkIdentifierNode(child, current, parent, key, scopes, ids) + } + } +} + +function walkIdentifierNode( + current: t.Node | null | undefined, + parent: t.Node | undefined, + grandparent: t.Node | undefined, + parentKey: string | undefined, + scopes: IdentifierScopeStack, + ids: Set, +) { + if (!current) return + + if (t.isIdentifier(current)) { + if ( + (!parent || t.isReferenced(current, parent, grandparent)) && + !hasIdentifierBinding(scopes, current.name) + ) { + 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 + } + + if (!hasIdentifierBinding(scopes, current.name)) { + ids.add(current.name) + } + return + } + + if (t.isProgram(current)) { + const nestedScopes = createNestedIdentifierScope('program', scopes) + addIdentifierBlockBindings(current.body, nestedScopes) + for (const child of current.body) { + walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids) + } + return + } + + if (t.isBlockStatement(current)) { + const nestedScopes = createNestedIdentifierScope('block', scopes) + addIdentifierBlockBindings(current.body, nestedScopes) + for (const child of current.body) { + walkIdentifierNode(child, current, parent, 'body', nestedScopes, ids) + } + return + } + + if ( + t.isFunctionDeclaration(current) || + t.isFunctionExpression(current) || + t.isArrowFunctionExpression(current) || + t.isObjectMethod(current) || + t.isClassMethod(current) || + t.isClassPrivateMethod(current) + ) { + if (t.isFunctionDeclaration(current) && current.id) { + currentIdentifierScope(scopes).bindings.add(current.id.name) + } + + const nestedScopes = createNestedIdentifierScope('function', scopes) + if ( + (t.isFunctionDeclaration(current) || t.isFunctionExpression(current)) && + current.id + ) { + currentIdentifierScope(nestedScopes).bindings.add(current.id.name) + } + for (const param of current.params) { + addIdentifierPatternBindings(param, currentIdentifierScope(nestedScopes)) + } + + walkIdentifierChildren(current, parent, nestedScopes, ids) + return + } + + if (t.isCatchClause(current)) { + const nestedScopes = createNestedIdentifierScope('block', scopes) + addIdentifierPatternBindings( + current.param, + currentIdentifierScope(nestedScopes), + ) + walkIdentifierNode( + current.param, + current, + parent, + 'param', + nestedScopes, + ids, + ) + walkIdentifierNode(current.body, current, parent, 'body', nestedScopes, ids) + return + } + + if (t.isImportDeclaration(current)) { + addIdentifierImportBindings(current, currentIdentifierScope(scopes)) + return + } + + if (t.isClassDeclaration(current) || t.isClassExpression(current)) { + if (t.isClassDeclaration(current) && current.id) { + currentIdentifierScope(scopes).bindings.add(current.id.name) + } + + const nestedScopes = current.id + ? createNestedIdentifierScope('block', scopes) + : scopes + if (current.id) { + currentIdentifierScope(nestedScopes).bindings.add(current.id.name) + } + + walkIdentifierChildren(current, parent, nestedScopes, ids) + return + } + + if (t.isVariableDeclaration(current)) { + addIdentifierDeclarationBindings(current, scopes) + } else if (t.isVariableDeclarator(current)) { + const scope = + parent && t.isVariableDeclaration(parent) && parent.kind === 'var' + ? nearestFunctionIdentifierScope(scopes) + : currentIdentifierScope(scopes) + addIdentifierPatternBindings(current.id, scope) + } else if ( + t.isTSTypeAliasDeclaration(current) || + t.isTSInterfaceDeclaration(current) || + t.isTSEnumDeclaration(current) + ) { + currentIdentifierScope(scopes).bindings.add(current.id.name) + } + + walkIdentifierChildren(current, parent, scopes, ids) +} + +/** + * 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() + walkIdentifierNode( + node, + undefined, + undefined, + undefined, + [{ kind: 'program', bindings: new Set() }], + ids, + ) + 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 + : t.isExportDefaultDeclaration(node) + ? 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 extractModuleInfoFromAst(ast: t.File): ExtractedModuleInfo { + const bindings = new Map() + const exportMap = new Map() + const reExportAllSources: Array = [] + + for (const node of ast.program.body) { + if (t.isImportDeclaration(node)) { + const source = node.source.value + for (const specifier of node.specifiers) { + if (t.isImportSpecifier(specifier)) { + bindings.set(specifier.local.name, { + type: 'import', + source, + importedName: getModuleExportName(specifier.imported), + }) + } else if (t.isImportDefaultSpecifier(specifier)) { + bindings.set(specifier.local.name, { + type: 'import', + source, + importedName: 'default', + }) + } else if (t.isImportNamespaceSpecifier(specifier)) { + bindings.set(specifier.local.name, { + type: 'import', + source, + importedName: '*', + }) + } + } + continue + } + + if (t.isVariableDeclaration(node)) { + addVariableDeclarationModuleInfo(node, bindings) + continue + } + + if (t.isFunctionDeclaration(node) || t.isClassDeclaration(node)) { + addDeclarationModuleInfo(node, bindings) + continue + } + + if (t.isExportNamedDeclaration(node)) { + if (node.declaration) { + addDeclarationModuleInfo(node.declaration, bindings, exportMap) + } + + for (const specifier of node.specifiers) { + if (t.isExportNamespaceSpecifier(specifier)) { + const exported = getModuleExportName(specifier.exported) + exportMap.set(exported, exported) + if (node.source) { + bindings.set(exported, { + type: 'import', + source: node.source.value, + importedName: '*', + }) + } + } else if (t.isExportSpecifier(specifier)) { + const local = getModuleExportName(specifier.local) + const exported = getModuleExportName(specifier.exported) + exportMap.set(exported, local) + + if (node.source) { + bindings.set(local, { + type: 'import', + source: node.source.value, + importedName: local, + }) + } + } + } + continue + } + + if (t.isExportDefaultDeclaration(node)) { + const declaration = node.declaration + if (t.isIdentifier(declaration)) { + exportMap.set('default', declaration.name) + } else if ( + (t.isFunctionDeclaration(declaration) || + t.isClassDeclaration(declaration)) && + declaration.id + ) { + bindings.set(declaration.id.name, { + type: 'var', + init: null, + }) + exportMap.set('default', declaration.id.name) + } else { + const synth = '__default_export__' + bindings.set(synth, { + type: 'var', + init: t.isExpression(declaration) ? declaration : null, + }) + exportMap.set('default', synth) + } + continue + } + + if (t.isExportAllDeclaration(node)) { + reExportAllSources.push(node.source.value) + } + } + + return { + bindings, + exports: exportMap, + reExportAllSources, + } +} + +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 + : t.isExportDefaultDeclaration(statement) + ? 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 3b072ae419..ce0de90824 100644 --- a/packages/router-utils/src/index.ts +++ b/packages/router-utils/src/index.ts @@ -9,3 +9,24 @@ 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, + extractModuleInfoFromAst, + removeBindingsTransitivelyDependingOn, + removeModuleLevelBindings, + retainModuleLevelDeclarations, + stripUnreferencedTopLevelExpressionStatements, + unwrapExportedDeclarations, +} from './compiler-helpers' +export type { ExtractedModuleInfo, ModuleInfoBinding } 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/router-utils/tests/compiler-helpers.test.ts b/packages/router-utils/tests/compiler-helpers.test.ts new file mode 100644 index 0000000000..00b75d67e5 --- /dev/null +++ b/packages/router-utils/tests/compiler-helpers.test.ts @@ -0,0 +1,249 @@ +import * as t from '@babel/types' +import { describe, expect, test } from 'vitest' +import { + buildDeclarationMap, + collectIdentifiersFromNode, + collectLocalBindingsFromStatement, + extractModuleInfoFromAst, +} from '../src/compiler-helpers' +import { parseAst } from '../src/ast' + +function getVariableInit(code: string) { + const ast = parseAst({ code, filename: 'test.tsx' }) + const declaration = ast.program.body.find((node) => + t.isVariableDeclaration(node), + ) as t.VariableDeclaration + return declaration.declarations[0]!.init! +} + +function collectSortedIdentifiers(node: t.Node) { + return [...collectIdentifiersFromNode(node)].sort() +} + +function collectSortedStatementBindings(code: string) { + const ast = parseAst({ code, filename: 'test.tsx' }) + const bindings = new Set() + for (const statement of ast.program.body) { + collectLocalBindingsFromStatement(statement, bindings) + } + return [...bindings].sort() +} + +function collectSortedDeclarationMapEntries(code: string) { + const ast = parseAst({ code, filename: 'test.tsx' }) + return [...buildDeclarationMap(ast)] + .map(([name, node]): [string, string] => [name, node.type]) + .sort((left, right) => (left[0] < right[0] ? -1 : 1)) +} + +function collectModuleInfoSnapshot(code: string) { + const ast = parseAst({ code, filename: 'test.tsx' }) + const info = extractModuleInfoFromAst(ast) + return { + bindings: [...info.bindings] + .map(([name, binding]) => [ + name, + binding.type === 'import' + ? `${binding.source}:${binding.importedName}` + : (binding.init?.type ?? null), + ]) + .sort((left, right) => (left[0]! < right[0]! ? -1 : 1)), + exports: [...info.exports].sort((left, right) => + left[0] < right[0] ? -1 : 1, + ), + reExportAllSources: info.reExportAllSources, + } +} + +describe('collectIdentifiersFromNode', () => { + test('collects free identifiers without reporting nested local bindings', () => { + const init = getVariableInit(` +const value = outer + (() => { + const local = dep + + function nested(param = fallback) { + const Inner = () => + const LocalExpr = class NamedLocal { + method() { + return NamedLocal + local + param + } + } + + class LocalComponent {} + + return local + param + imported + Inner + LocalExpr + } + + return nested() +})() +`) + + expect(collectSortedIdentifiers(init)).toMatchInlineSnapshot(` + [ + "dep", + "fallback", + "imported", + "outer", + ] + `) + }) + + test('respects nested variable, parameter, catch, and import shadowing', () => { + const ast = parseAst({ + code: ` +import { external as localImport } from 'pkg' + +const value = (localParam = defaultValue) => { + const localShadow = factory(localImport) + + try { + throw thrown + } catch (thrown) { + const factory = () => localShadow + localParam + thrown + return factory() + } +} +`, + filename: 'test.ts', + }) + + expect(collectSortedIdentifiers(ast.program)).toMatchInlineSnapshot(` + [ + "defaultValue", + "factory", + "thrown", + ] + `) + }) +}) + +describe('collectLocalBindingsFromStatement', () => { + test('collects ids from named default function and class declarations', () => { + expect({ + class: collectSortedStatementBindings( + `export default class DefaultComponent {}`, + ), + function: collectSortedStatementBindings( + `export default function DefaultRoute() {}`, + ), + anonymous: collectSortedStatementBindings( + `export default function () {}`, + ), + }).toMatchInlineSnapshot(` + { + "anonymous": [], + "class": [ + "DefaultComponent", + ], + "function": [ + "DefaultRoute", + ], + } + `) + }) +}) + +describe('buildDeclarationMap', () => { + test('maps named default function and class declarations', () => { + expect({ + class: collectSortedDeclarationMapEntries( + `export default class DefaultComponent {}`, + ), + function: collectSortedDeclarationMapEntries( + `export default function DefaultRoute() {}`, + ), + anonymous: collectSortedDeclarationMapEntries(`export default class {}`), + }).toMatchInlineSnapshot(` + { + "anonymous": [], + "class": [ + [ + "DefaultComponent", + "ClassDeclaration", + ], + ], + "function": [ + [ + "DefaultRoute", + "FunctionDeclaration", + ], + ], + } + `) + }) +}) + +describe('extractModuleInfoFromAst', () => { + test('extracts imports, local exports, default exports, and re-export sources', () => { + expect( + collectModuleInfoSnapshot(` + import defaultImport, { named as localNamed } from 'pkg' + import * as ns from 'pkg-ns' + const local = localNamed + export const exported = local + export function loader() {} + export default class DefaultRoute {} + export { remote as renamed } from './remote' + export * from './all' + `), + ).toMatchInlineSnapshot(` + { + "bindings": [ + [ + "DefaultRoute", + null, + ], + [ + "defaultImport", + "pkg:default", + ], + [ + "exported", + "Identifier", + ], + [ + "loader", + null, + ], + [ + "local", + "Identifier", + ], + [ + "localNamed", + "pkg:named", + ], + [ + "ns", + "pkg-ns:*", + ], + [ + "remote", + "./remote:remote", + ], + ], + "exports": [ + [ + "default", + "DefaultRoute", + ], + [ + "exported", + "exported", + ], + [ + "loader", + "loader", + ], + [ + "renamed", + "remote", + ], + ], + "reExportAllSources": [ + "./all", + ], + } + `) + }) +}) diff --git a/packages/router-utils/tests/stripTypeExports.test.ts b/packages/router-utils/tests/stripTypeExports.test.ts index d0c6af3ad7..32d25f1cbe 100644 --- a/packages/router-utils/tests/stripTypeExports.test.ts +++ b/packages/router-utils/tests/stripTypeExports.test.ts @@ -123,7 +123,7 @@ type TypeOnly = string; export { value, type TypeOnly };` const result = transform(code) expect(result).toContain('export { value }') - expect(result).not.toContain('TypeOnly') + expect(result).toContain('type TypeOnly = string') }) test('removes entire export if all specifiers are type-only', () => { @@ -133,8 +133,8 @@ export { type Foo, type Bar }; export const value = 1;` const result = transform(code) expect(result).not.toContain('export { type Foo') - expect(result).not.toContain('Foo') - expect(result).not.toContain('Bar') + expect(result).toContain('type Foo = string') + expect(result).toContain('type Bar = number') expect(result).toContain('export const value = 1') }) }) @@ -233,9 +233,11 @@ export { helper, type HelperType };` expect(result).not.toContain('./types') expect(result).not.toContain('HelperType') - // Should remove type declarations - expect(result).not.toContain('type LocalType') - expect(result).not.toContain('interface LocalInterface') + // Should preserve local type declarations + expect(result).toContain('type LocalType = string') + expect(result).toContain('interface LocalInterface') + + // Should remove exported type declarations expect(result).not.toContain('type ExportedType') expect(result).not.toContain('interface ExportedInterface') diff --git a/packages/solid-router/src/ClientOnly.tsx b/packages/solid-router/src/ClientOnly.tsx index 6ed10d267b..a7c4b71819 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 4537f11800..5e0c978f81 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 0000000000..e23c057f34 --- /dev/null +++ b/packages/solid-start-client/src/GenericHydrate.tsx @@ -0,0 +1,376 @@ +import * as Solid from 'solid-js' +import { Dynamic } 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, + getFallbackHtml, + getOrCreateGate, + onGateResolve, + releaseGate, + runHydrationStrategyCleanup, + saveFallbackHtml, + waitForHydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration/runtime' +import { listenForDelegatedHydrationIntent } from '@tanstack/start-client-core/hydration' +import type { + HydrationRuntimeContext, + HydrationStrategy, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' +import type { HydrationGateRecord } from '@tanstack/start-client-core/hydration/runtime' +import type { InternalHydrateProps } from './Hydrate' +import type { DynamicProps } from 'solid-js/web' + +type HydrationFallbackDynamicProps = DynamicProps<'div'> +type HydrationMarkerDynamicProps = DynamicProps<'div'> & { + [hydrateIdAttribute]: string + [hydrateWhenAttribute]: HydrationWhen + [key: `data-${string}`]: string | undefined +} +type PrefetchController = { + abortController: AbortController + hydrationRequested: boolean + hydrationListeners: Set<() => void> + hydrationResolvePending: boolean + started: boolean + promise?: Promise +} + +const hydrateIdSelector = `[${hydrateIdAttribute}]` +const dynamicType = 'dynamic' +const dynamicHydrateStrategy = { + _t: dynamicType, + _d: () => true, +} satisfies HydrationStrategy + +function shouldDeferHydration(strategy: HydrationStrategy) { + return strategy._d ? strategy._d() : strategy._t !== 'load' +} + +/* @__NO_SIDE_EFFECTS__ */ +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 +} + +/* @__NO_SIDE_EFFECTS__ */ +export function GenericHydrate(props: InternalHydrateProps) { + const when = props.when + const dynamicHydrate = typeof when === 'function' + const initialHydrateStrategy: HydrationStrategy = dynamicHydrate + ? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') + ? dynamicHydrateStrategy + : when() + : when + const markerHydrateType: HydrationWhen = dynamicHydrate + ? dynamicType + : initialHydrateStrategy._t! + const prefetchStrategy = () => props.prefetch + const hydrated = useHydrated() + const uniqueId = Solid.createUniqueId() + const id = props.h ? `${props.h}${uniqueId}` : uniqueId + const initialHydrateType = initialHydrateStrategy._t! + const shouldPreserveServerHTML = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') || !hydrated() + const shouldDeferInitialHydration = + !hydrated() && shouldDeferHydration(initialHydrateStrategy) + const gate: HydrationGateRecord = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') + ? createResolvedGate(id, initialHydrateType) + : getOrCreateGate(id, initialHydrateType) + const [ready, setReady] = Solid.createSignal( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') || + (!shouldDeferInitialHydration && initialHydrateType !== 'never'), + ) + const [prefetchError, setPrefetchError] = Solid.createSignal() + const controller: PrefetchController = { + abortController: new AbortController(), + hydrationRequested: false, + hydrationListeners: new Set<() => void>(), + hydrationResolvePending: false, + started: false, + } + let didPrefetch = false + let markerElement: HTMLDivElement | undefined + + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + !(isServer ?? typeof window === 'undefined') && + initialHydrateType !== 'never' && + (!shouldDeferInitialHydration || + !shouldDeferHydration(initialHydrateStrategy)) + ) { + gate.resolve() + } + + const onHydrate = (listener: () => void) => { + if (controller.hydrationRequested) { + listener() + return () => {} + } + + controller.hydrationListeners.add(listener) + return () => { + controller.hydrationListeners.delete(listener) + } + } + + const requestHydration = () => { + if (!controller.hydrationRequested) { + controller.hydrationRequested = true + controller.hydrationListeners.forEach((listener) => listener()) + controller.hydrationListeners.clear() + } + + if (!controller.promise) { + resolveGate() + return + } + if (controller.hydrationResolvePending) return + controller.hydrationResolvePending = true + + controller.promise.then( + () => resolveGate(), + (error) => { + if (!controller.abortController.signal.aborted) { + setPrefetchError(() => error) + } + }, + ) + } + const resolveGate = gate.resolve + + Solid.onMount(() => { + const currentHydrateStrategy = initialHydrateStrategy + const currentPrefetchStrategy = prefetchStrategy() + const currentHydrateType = currentHydrateStrategy._t! + gate.when = currentHydrateType + for (const element of document.querySelectorAll( + hydrateIdSelector, + )) { + if (element.getAttribute(hydrateIdAttribute) === id) { + markerElement = element + saveFallbackHtml(id, element) + break + } + } + + if ( + currentHydrateType === 'never' && + !shouldPreserveServerHTML && + markerElement + ) { + markerElement.replaceChildren() + } + + if (currentPrefetchStrategy && !controller.started) { + controller.started = true + const preload = () => props.p?.() ?? Promise.resolve() + + if (typeof currentPrefetchStrategy === 'function') { + const promise = Promise.resolve() + .then(() => + currentPrefetchStrategy({ + element: markerElement ?? null, + signal: controller.abortController.signal, + preload, + waitFor: (strategy) => + waitForHydrationPrefetchStrategy(strategy, { + element: markerElement ?? null, + signal: controller.abortController.signal, + onHydrate, + }), + }), + ) + .then(() => undefined) + + controller.promise = promise + promise.catch((error) => { + if (!controller.abortController.signal.aborted) { + setPrefetchError(() => error) + } + }) + } else if (props.p) { + const currentStrategy = currentPrefetchStrategy + const prefetch = () => { + if (didPrefetch) return + didPrefetch = true + void preload() + } + const cleanupPrefetch = runHydrationStrategyCleanup( + currentStrategy._s?.({ + element: markerElement ?? null, + prefetch, + }), + ) + if (cleanupPrefetch) Solid.onCleanup(cleanupPrefetch) + } + } + + if ( + currentHydrateType !== 'never' && + (!shouldDeferInitialHydration || + !shouldDeferHydration(currentHydrateStrategy)) + ) { + gate.resolve() + setReady(true) + } + + const cleanups: Array<() => void> = [] + let removeResolveListener = () => {} + let disposed = false + + const resolveBoundary = () => { + setReady(true) + } + + const cleanup = () => { + if (disposed) return + disposed = true + if (gate.resolve === requestHydration) { + gate.resolve = resolveGate + } + removeResolveListener() + cleanups.forEach((fn) => fn()) + } + + const addCleanup = (fn: void | (() => void)) => { + if (!fn) return + if (disposed || gate.resolved) { + fn() + return + } + cleanups.push(fn) + } + + Solid.onCleanup(() => { + controller.abortController.abort() + controller.hydrationListeners.clear() + cleanup() + releaseGate(gate) + }) + + removeResolveListener = onGateResolve(gate, () => { + cleanup() + resolveBoundary() + }) + + if ( + gate.resolved || + !shouldDeferInitialHydration || + currentHydrateType === 'never' + ) { + if (gate.resolved) resolveBoundary() + return + } + + gate.resolve = requestHydration + const context: HydrationRuntimeContext = { + element: markerElement ?? null, + gate, + } + addCleanup( + runHydrationStrategyCleanup(currentHydrateStrategy._s?.(context)), + ) + + if (currentHydrateStrategy._t !== 'interaction') { + addCleanup( + runHydrationStrategyCleanup( + markerElement + ? listenForDelegatedHydrationIntent(markerElement, context) + : undefined, + ), + ) + } + }) + + Solid.createRenderEffect(() => { + if ( + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (isServer ?? typeof window === 'undefined') || + gate.resolved || + initialHydrateStrategy._t === 'never' || + shouldDeferHydration(initialHydrateStrategy) + ) { + return + } + + gate.resolve() + }) + + const markerAttributes = + markerHydrateType === dynamicType + ? undefined + : initialHydrateStrategy._a?.() + const markerProps: HydrationMarkerDynamicProps = { + component: 'div', + [hydrateIdAttribute]: id, + [hydrateWhenAttribute]: markerHydrateType, + ...markerAttributes, + } + const fallback = () => { + if (!shouldPreserveServerHTML) return props.fallback ?? null + + const html = getFallbackHtml(id) + if (!html) return null + + const fallbackProps: HydrationFallbackDynamicProps = { + component: 'div', + style: { display: 'contents' }, + innerHTML: html, + } + + return + } + + return ( + + {(() => { + const error = prefetchError() + if (error) throw error + return null + })()} + {initialHydrateType === 'never' && !shouldPreserveServerHTML ? ( + (props.fallback ?? null) + ) : ( + + + { + markerElement?.removeAttribute(hydrateWhenAttribute) + initialHydrateStrategy._o?.(id) + }} + > + {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 0000000000..1b52e643f2 --- /dev/null +++ b/packages/solid-start-client/src/Hydrate.tsx @@ -0,0 +1,75 @@ +import { GenericHydrate } from './GenericHydrate' +import type { + HydrationStrategy as CoreHydrationStrategy, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' +import type * as Solid from 'solid-js' + +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationWhen, +} from '@tanstack/start-client-core/hydration' + +export type SolidHydrationStrategy< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = CoreHydrationStrategy & { + _h: (props: HydrateProps) => Solid.JSX.Element +} + +export type HydrationStrategy< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = SolidHydrationStrategy + +export type HydrateWhen = + | SolidHydrationStrategy + | (() => SolidHydrationStrategy) + +type HydrateCommonOptions = { + when: HydrateWhen + fallback?: Solid.JSX.Element + onHydrated?: () => void +} + +export type HydrateOptions = + | (HydrateCommonOptions & { + prefetch?: never + split?: boolean + }) + | (HydrateCommonOptions & { + prefetch: HydrationPrefetchStrategy + split?: true + }) + | (HydrateCommonOptions & { + prefetch: HydrationPrefetchFunction + split?: boolean + }) + +export type HydrateProps = HydrateOptions & { + children: Solid.JSX.Element +} + +export type InternalHydrateProps = HydrateProps & { + h?: string + p?: () => Promise +} + +/* @__NO_SIDE_EFFECTS__ */ +export function Hydrate(props: HydrateProps) { + if ( + typeof props.when === 'function' || + typeof props.prefetch === 'function' + ) { + return + } + + return props.when._h(props) +} diff --git a/packages/solid-start-client/src/hydration.ts b/packages/solid-start-client/src/hydration.ts new file mode 100644 index 0000000000..bd875dcb0a --- /dev/null +++ b/packages/solid-start-client/src/hydration.ts @@ -0,0 +1,20 @@ +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, + IdleHydrationOptions, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchWhen, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationStrategyTypes, + 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 0000000000..bfc1e8db9f --- /dev/null +++ b/packages/solid-start-client/src/hydration/generic.ts @@ -0,0 +1,41 @@ +import { + condition as coreCondition, + interaction as coreInteraction, + media as coreMedia, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationCondition, + HydrationInteractionEvents, + HydrationPrefetchStrategy, +} from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function media( + query: string, +): SolidHydrationStrategy<'media', true> & HydrationPrefetchStrategy<'media'> { + return /* @__PURE__ */ withHydrationRenderer(coreMedia(query), GenericHydrate) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function condition( + condition: HydrationCondition, +): SolidHydrationStrategy<'condition', false> { + return /* @__PURE__ */ withHydrationRenderer( + coreCondition(condition), + GenericHydrate, + ) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction(options?: { + events?: HydrationInteractionEvents +}): SolidHydrationStrategy<'interaction', true> & + HydrationPrefetchStrategy<'interaction'> { + return /* @__PURE__ */ withHydrationRenderer( + coreInteraction(options), + GenericHydrate, + ) +} 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 0000000000..89282b0a95 --- /dev/null +++ b/packages/solid-start-client/src/hydration/idle.ts @@ -0,0 +1,20 @@ +import { + idle as coreIdle, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationPrefetchStrategy, + IdleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function idle( + options: IdleHydrationOptions = {}, +): SolidHydrationStrategy<'idle', true> & HydrationPrefetchStrategy<'idle'> { + return /* @__PURE__ */ withHydrationRenderer( + coreIdle(options), + GenericHydrate, + ) +} 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 0000000000..a2836231e8 --- /dev/null +++ b/packages/solid-start-client/src/hydration/load.tsx @@ -0,0 +1,13 @@ +import { + load as coreLoad, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { HydrationPrefetchStrategy } from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): SolidHydrationStrategy<'load', true> & + HydrationPrefetchStrategy<'load'> { + return /* @__PURE__ */ withHydrationRenderer(coreLoad(), GenericHydrate) +} 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 0000000000..36513b95d4 --- /dev/null +++ b/packages/solid-start-client/src/hydration/never.ts @@ -0,0 +1,11 @@ +import { + never as coreNever, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function never(): SolidHydrationStrategy<'never', false> { + return /* @__PURE__ */ withHydrationRenderer(coreNever(), 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 0000000000..c747c236ec --- /dev/null +++ b/packages/solid-start-client/src/hydration/visible.tsx @@ -0,0 +1,21 @@ +import { + visible as coreVisible, + withHydrationRenderer, +} from '@tanstack/start-client-core/hydration' +import { GenericHydrate } from '../GenericHydrate' +import type { + HydrationPrefetchStrategy, + VisibleHydrationOptions, +} from '@tanstack/start-client-core/hydration' +import type { SolidHydrationStrategy } from '../Hydrate' + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options?: VisibleHydrationOptions, +): SolidHydrationStrategy<'visible', true> & + HydrationPrefetchStrategy<'visible'> { + return /* @__PURE__ */ withHydrationRenderer( + coreVisible(options), + GenericHydrate, + ) +} diff --git a/packages/solid-start-client/src/index.tsx b/packages/solid-start-client/src/index.tsx index aa73990a57..2128ebcb01 100644 --- a/packages/solid-start-client/src/index.tsx +++ b/packages/solid-start-client/src/index.tsx @@ -1,2 +1,16 @@ export { StartClient } from './StartClient' export { hydrateStart } from './hydrateStart' +export { Hydrate } from './Hydrate' +export type { + HydrateOptions, + HydrateProps, + HydrateWhen, + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationStrategy, + HydrationWhen, +} from './Hydrate' 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 0000000000..e5e13334f3 --- /dev/null +++ b/packages/solid-start-client/src/tests/Hydrate.test-d.tsx @@ -0,0 +1,147 @@ +import { expectTypeOf, test } from 'vitest' +import { visible } from '../hydration' +import { Hydrate } from '../Hydrate' +import type { + HydrateOptions, + HydrateProps, + HydrationPrefetchFunction, + HydrationPrefetchStrategy, + HydrationStrategy, +} from '../Hydrate' +import type { HydrationStrategy as CoreHydrationStrategy } from '@tanstack/start-client-core/hydration' +import type { JSX } from 'solid-js' + +type CommonHydrateProps = { + fallback?: JSX.Element + onHydrated?: () => void + children: JSX.Element +} + +type SplitHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch?: never + split?: boolean +} + +type PrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch: HydrationPrefetchStrategy + split?: true +} + +type FunctionPrefetchHydrateProps = CommonHydrateProps & { + when: HydrationStrategy | (() => HydrationStrategy) + prefetch: HydrationPrefetchFunction + split?: boolean +} + +test('Hydrate component accepts the public HydrateProps type', () => { + expectTypeOf(Hydrate).toBeFunction() + expectTypeOf(Hydrate).parameter(0).branded.toEqualTypeOf() +}) + +test('HydrateOptions supports reusable spread props', () => { + const belowFoldProps = { + when: () => visible({ rootMargin: '800px' }), + } satisfies HydrateOptions + + expectTypeOf(belowFoldProps).toMatchTypeOf() + + const withFunctionPrefetch = { + when: visible(), + split: false, + prefetch: (ctx) => { + expectTypeOf(ctx.element).toEqualTypeOf() + expectTypeOf(ctx.signal).toEqualTypeOf() + expectTypeOf(ctx.preload).returns.toEqualTypeOf>() + expectTypeOf(ctx.waitFor).returns.toEqualTypeOf< + Promise<'prefetch' | 'hydrate' | 'abort'> + >() + }, + } satisfies HydrateOptions + + expectTypeOf(withFunctionPrefetch).toMatchTypeOf() +}) + +test('Hydrate props are exact for strategy and prefetch forms', () => { + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() + expectTypeOf< + Extract + >().branded.toEqualTypeOf() +}) + +test('Hydrate requires a strategy', () => { + expectTypeOf<{ + when: HydrationStrategy + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + when: () => HydrationStrategy + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + children: JSX.Element + }>().not.toMatchTypeOf() + + expectTypeOf<{ + when: () => true + children: JSX.Element + }>().not.toMatchTypeOf() + + expectTypeOf<{ + when: false + 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() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchFunction + split: false + children: JSX.Element + }>().toMatchTypeOf() + + expectTypeOf<{ + when: HydrationStrategy + prefetch: HydrationPrefetchFunction + children: JSX.Element + }>().toMatchTypeOf() +}) diff --git a/packages/solid-start-client/vite.config.ts b/packages/solid-start-client/vite.config.ts index be71bab518..dbfb60ea03 100644 --- a/packages/solid-start-client/vite.config.ts +++ b/packages/solid-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/Hydrate.tsx', './src/hydration.ts'], cjs: false, }), ) diff --git a/packages/solid-start/package.json b/packages/solid-start/package.json index a6999021e4..4ab9a7ff8e 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 0000000000..fc660e9dc5 --- /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 8b51b6c783..0cdac87c81 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 } 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 262208de09..2c1a42d0e4 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 76df615958..e0443bae20 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/client/hydrateStart.ts b/packages/start-client-core/src/client/hydrateStart.ts index 206b70505c..48b58158bc 100644 --- a/packages/start-client-core/src/client/hydrateStart.ts +++ b/packages/start-client-core/src/client/hydrateStart.ts @@ -10,7 +10,19 @@ import { ServerFunctionSerializationAdapter } from './ServerFunctionSerializatio import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' import type { AnyStartInstanceOptions } from '../createStart' -export async function hydrateStart(): Promise { +type HotContext = { + data?: Record + dispose?: (cb: (data: Record) => void) => void +} + +declare global { + interface ImportMeta { + hot?: HotContext + webpackHot?: HotContext + } +} + +async function hydrateStart(): Promise { const router = await getRouter() let serializationAdapters: Array @@ -47,3 +59,38 @@ export async function hydrateStart(): Promise { return router } + +function hydrateStartWithHmr(): Promise { + const hot = import.meta.hot ?? import.meta.webpackHot + + if (!hot) { + return hydrateStart() + } + + const key = 'tss-hydrate-start-promise' + const hotData = (hot.data ??= {}) + let hydrationPromise = hotData[key] as Promise | undefined + + if (!hydrationPromise) { + hydrationPromise = hydrateStart().catch((error) => { + if (hotData[key] === hydrationPromise) { + hotData[key] = undefined + } + + throw error + }) + + hotData[key] = hydrationPromise + } + + hot.dispose?.((data) => { + data[key] = hotData[key] + }) + + return hydrationPromise +} + +const exportedHydrateStart = + process.env.NODE_ENV !== 'production' ? hydrateStartWithHmr : hydrateStart + +export { exportedHydrateStart as hydrateStart } diff --git a/packages/start-client-core/src/hydration.ts b/packages/start-client-core/src/hydration.ts new file mode 100644 index 0000000000..7a2665e998 --- /dev/null +++ b/packages/start-client-core/src/hydration.ts @@ -0,0 +1,50 @@ +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, scheduleIdle } from './hydration/idle' +export type { IdleHydrationOptions } 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, + runHydrationStrategyCleanup, + saveFallbackHtml, + waitForHydrationPrefetchStrategy, +} from './hydration/runtime' +export { withHydrationRenderer } from './hydration/renderer' +export { visible } from './hydration/visible' +export { listenForDelegatedHydrationIntent } from './hydration/interaction' +export type { VisibleHydrationOptions } from './hydration/visible' +export type { HydrationGateRecord } from './hydration/runtime' +export type { HydrationStrategyWithRenderer } from './hydration/renderer' +export type { + HydrationInteractionEvent, + HydrationInteractionEvents, + HydrationMarkerAttributes, + HydrationPrefetchContext, + HydrationPrefetchFunction, + HydrationPrefetchWhen, + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationRuntimeContext, + HydrationRuntimeGate, + HydrationStrategy, + HydrationStrategyTypes, + 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 0000000000..6e640cc3ae --- /dev/null +++ b/packages/start-client-core/src/hydration/condition.ts @@ -0,0 +1,20 @@ +import type { HydrationStrategy } from './types' + +const conditionType = 'condition' + +export type HydrationCondition = boolean | (() => boolean) + +/* @__NO_SIDE_EFFECTS__ */ +export function condition( + condition: HydrationCondition, +): HydrationStrategy { + return { + _t: conditionType, + _d: () => !(typeof condition === 'function' ? condition() : condition), + _s: ({ gate }) => { + if (typeof condition === 'function' ? condition() : 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 0000000000..4b7a29313d --- /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 0000000000..40bcf6e59b --- /dev/null +++ b/packages/start-client-core/src/hydration/idle.ts @@ -0,0 +1,39 @@ +import type { HydrationPrefetchStrategy } from './types' + +const idleType = 'idle' + +export type IdleHydrationOptions = { + timeout?: number +} + +type IdleScheduler = { + requestIdleCallback?: ( + callback: IdleRequestCallback, + options?: IdleRequestOptions, + ) => number + cancelIdleCallback?: (handle: number) => void +} + +export function scheduleIdle(callback: () => void, timeout: number) { + const schedule = globalThis as unknown as IdleScheduler + 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 = {}, +): HydrationPrefetchStrategy { + const timeout = options.timeout ?? 2000 + + return { + _t: idleType, + _s: ({ gate, prefetch }) => + scheduleIdle(prefetch ?? gate!.resolve, 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 0000000000..d5d6886d36 --- /dev/null +++ b/packages/start-client-core/src/hydration/interaction.ts @@ -0,0 +1,342 @@ +import { + hydrateIdAttribute, + hydrateInteractionEventsAttribute, + hydrateWhenAttribute, +} from './constants' +import { + clearResolvedGateIdsInMarker, + getMarkerGate, + resolveHydrationMarker, +} from './runtime' +import type { + HydrationInteractionEvents, + HydrationPrefetchStrategy, + HydrationRuntimeContext, +} 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 supportedInteractionEvents = [ + 'auxclick', + 'click', + 'contextmenu', + 'dblclick', + 'focusin', + 'keydown', + 'keyup', + 'mousedown', + 'mouseenter', + 'mouseover', + 'mouseup', + 'pointerdown', + 'pointerenter', + 'pointerover', + 'pointerup', +] as const +const interactionType = 'interaction' +const dynamicType = 'dynamic' +const interactionHydrateSelector = `[${hydrateWhenAttribute}="${interactionType}"]` +const delegatedHydrateSelector = `${interactionHydrateSelector},[${hydrateWhenAttribute}="${dynamicType}"]` +const replayEventsByGateId = /* @__PURE__ */ new Map< + string, + Array +>() + +function getIntentListenerEvents( + marker: Element, + events: ReadonlyArray, +) { + const listenerEvents = new Set(events) + + marker.querySelectorAll(delegatedHydrateSelector).forEach((childMarker) => { + if (childMarker.getAttribute(hydrateWhenAttribute) === dynamicType) { + supportedInteractionEvents.forEach((eventName) => { + listenerEvents.add(eventName) + }) + return + } + + const attr = childMarker.getAttribute(hydrateInteractionEventsAttribute) + for (const eventName of attr === null + ? defaultInteractionEvents + : attr.split(/\s+/).filter(Boolean)) { + listenerEvents.add(eventName) + } + }) + + return [...listenerEvents] +} + +function queueHydrationReplayEvent(marker: Element, event: Event) { + if (!event.bubbles) return + + const id = marker.getAttribute(hydrateIdAttribute) + const when = marker.getAttribute(hydrateWhenAttribute) + if (!id || !when || when === 'never') return + + const target = event.target + if (!target) return + + const gate = getMarkerGate(marker) + if (gate?.resolved) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + let targetPath: Array = [] + if (target instanceof Node && marker.contains(target)) { + let node: Element | null = + target instanceof Element ? target : target.parentElement + + while (node && node !== marker) { + const parent = node.parentElement + if (!parent) { + targetPath = [] + break + } + targetPath.push(Array.prototype.indexOf.call(parent.children, node)) + node = parent + } + targetPath.reverse() + } + + const pendingEvents = replayEventsByGateId.get(id) ?? [] + pendingEvents.push({ + marker, + targetPath, + type: event.type, + event, + }) + replayEventsByGateId.set(id, pendingEvents) +} + +if (typeof document !== 'undefined') { + const onIntent = (event: Event) => { + const target = event.target + if (!(target instanceof Element)) return + + let marker: Element | null = target.closest(hydrateIdSelector) + const markers: Array = [] + let shouldHandle = false + + while (marker) { + markers.push(marker) + + const when = marker.getAttribute(hydrateWhenAttribute) + if (when === dynamicType) { + shouldHandle ||= event.type === 'click' + } else if (when === interactionType) { + const attr = marker.getAttribute(hydrateInteractionEventsAttribute) + const events: ReadonlyArray = + attr === null + ? defaultInteractionEvents + : attr.split(/\s+/).filter(Boolean) + shouldHandle ||= events.includes(event.type) + } + + marker = marker.parentElement?.closest(hydrateIdSelector) ?? null + } + + if (!shouldHandle) return + + markers.reverse() + if (markers.every((marker) => getMarkerGate(marker))) return + + markers.forEach((marker) => { + queueHydrationReplayEvent(marker, event) + resolveHydrationMarker(marker) + }) + } + + supportedInteractionEvents.forEach((eventName) => { + document.addEventListener(eventName, onIntent, true) + }) +} + +function listenForIntent( + element: Element, + events: ReadonlyArray, + context: HydrationRuntimeContext, +) { + const onIntent = (event: Event) => { + const target = event.target + let marker: Element | null + if (target instanceof Element) { + const closestMarker = target.closest(hydrateIdSelector) + marker = + closestMarker && element.contains(closestMarker) + ? closestMarker + : element + } else { + marker = element + } + + const markers: Array = [] + while (marker) { + if (marker.hasAttribute(hydrateIdAttribute)) { + markers.push(marker) + } + if (marker === element) break + marker = marker.parentElement + } + + if (!markers.includes(element)) { + markers.push(element) + } + + markers.reverse() + + if ( + context.delegated && + !markers.some( + (marker) => + marker.getAttribute(hydrateWhenAttribute) === interactionType || + marker.getAttribute(hydrateWhenAttribute) === dynamicType, + ) + ) { + 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) + }) + } +} + +export function listenForDelegatedHydrationIntent( + element: Element, + context: HydrationRuntimeContext, +) { + const listenerEvents = getIntentListenerEvents(element, []) + if (!listenerEvents.length) return + + const cleanupIntent = listenForIntent(element, listenerEvents, { + ...context, + delegated: true, + }) + return () => { + cleanupIntent() + clearResolvedGateIdsInMarker(element) + } +} + +/* @__NO_SIDE_EFFECTS__ */ +export function interaction( + options: InteractionHydrationOptions = {}, +): HydrationPrefetchStrategy { + let events: ReadonlyArray = defaultInteractionEvents + if (options.events !== undefined) { + const eventList: ReadonlyArray = + typeof options.events === 'string' ? [options.events] : options.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) + } + + events = normalizedEvents + } + + const eventKey = events.join(' ') + + return { + _t: interactionType, + _s: (context) => { + const element = context.element + if (!element) return + const prefetch = context.prefetch + if (prefetch) { + if (!events.length) return + 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) + }) + } + } + + const listenerEvents = getIntentListenerEvents(element, events) + const cleanupIntent = listenerEvents.length + ? listenForIntent(element, listenerEvents, context) + : undefined + return () => { + cleanupIntent?.() + clearResolvedGateIdsInMarker(element) + } + }, + _o: (id) => { + globalThis.requestAnimationFrame(() => { + 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 + } + + const event = pendingEvent.event + replayTarget ??= pendingEvent.marker + replayTarget.dispatchEvent( + event instanceof MouseEvent + ? new MouseEvent(event.type, event) + : event instanceof FocusEvent + ? new FocusEvent(event.type, event) + : new Event(event.type, event), + ) + } + }) + }, + _a: () => + 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 0000000000..b78d6d6a42 --- /dev/null +++ b/packages/start-client-core/src/hydration/load.ts @@ -0,0 +1,16 @@ +import type { HydrationPrefetchStrategy } from './types' + +const loadType = 'load' + +const loadStrategy: HydrationPrefetchStrategy = { + _t: loadType, + _d: () => false, + _s: ({ gate, prefetch }) => { + ;(prefetch ?? gate!.resolve)() + }, +} + +/* @__NO_SIDE_EFFECTS__ */ +export function load(): 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 0000000000..c2335b7e2f --- /dev/null +++ b/packages/start-client-core/src/hydration/media.ts @@ -0,0 +1,25 @@ +import type { HydrationPrefetchStrategy } from './types' + +const mediaType = 'media' + +/* @__NO_SIDE_EFFECTS__ */ +export function media( + query: string, +): HydrationPrefetchStrategy { + return { + _t: mediaType, + _s: ({ gate, prefetch }) => { + if (!query) return + + const callback = prefetch ?? gate!.resolve + const mediaQuery = window.matchMedia(query) + const onChange = () => { + if (mediaQuery.matches) callback() + } + mediaQuery.addEventListener('change', onChange) + onChange() + + return () => mediaQuery.removeEventListener('change', onChange) + }, + } +} 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 0000000000..8ace129b5e --- /dev/null +++ b/packages/start-client-core/src/hydration/never.ts @@ -0,0 +1,13 @@ +import type { HydrationStrategy } from './types' + +const neverType = 'never' + +const neverStrategy: HydrationStrategy = { + _t: neverType, + _d: () => true, +} + +/* @__NO_SIDE_EFFECTS__ */ +export function never(): HydrationStrategy { + return neverStrategy +} diff --git a/packages/start-client-core/src/hydration/renderer.ts b/packages/start-client-core/src/hydration/renderer.ts new file mode 100644 index 0000000000..1a14ef9b46 --- /dev/null +++ b/packages/start-client-core/src/hydration/renderer.ts @@ -0,0 +1,21 @@ +import type { HydrationStrategy } from './types' + +export type HydrationStrategyWithRenderer< + TStrategy extends HydrationStrategy, + TRenderer, +> = TStrategy & { + _h: TRenderer +} + +/* @__NO_SIDE_EFFECTS__ */ +export function withHydrationRenderer< + TStrategy extends HydrationStrategy, + TRenderer, +>( + strategy: TStrategy, + renderer: TRenderer, +): HydrationStrategyWithRenderer { + return /* @__PURE__ */ Object.assign(strategy, { + _h: renderer, + }) +} 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 0000000000..b1da55195d --- /dev/null +++ b/packages/start-client-core/src/hydration/runtime.ts @@ -0,0 +1,191 @@ +import { hydrateIdAttribute, hydrateWhenAttribute } from './constants' +import type { + HydrationPrefetchStrategy, + HydrationPrefetchWaitReason, + HydrationRuntimeGate, + HydrationWhen, +} from './types' + +const hydrateIdSelector = `[${hydrateIdAttribute}]` + +export type HydrationGateRecord = HydrationRuntimeGate & { + id: string + when: HydrationWhen + promise: Promise + consumers: number + resolveListeners: Set<() => void> +} + +const gateRegistry = /* @__PURE__ */ new Map() +const resolvedGateIds = /* @__PURE__ */ new Set() +const fallbackHtmlByGateId = /* @__PURE__ */ 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 runHydrationStrategyCleanup(cleanup: void | (() => void)) { + if (typeof cleanup === 'function') return cleanup + return undefined +} + +export function waitForHydrationPrefetchStrategy( + strategy: HydrationPrefetchStrategy, + options: { + element: Element | null + signal: AbortSignal + onHydrate: (listener: () => void) => () => void + }, +): Promise { + if (options.signal.aborted) { + return Promise.resolve('abort') + } + + return new Promise((resolve) => { + const state = { disposed: false } + const cleanupStrategyRef: { current: void | (() => void) } = { + current: undefined, + } + let cleanupHydrate = () => {} + + const finish = (reason: HydrationPrefetchWaitReason) => { + if (state.disposed) return + state.disposed = true + options.signal.removeEventListener('abort', onAbort) + cleanupHydrate() + runHydrationStrategyCleanup(cleanupStrategyRef.current)?.() + resolve(reason) + } + + const onAbort = () => finish('abort') + + options.signal.addEventListener('abort', onAbort, { once: true }) + cleanupHydrate = options.onHydrate(() => finish('hydrate')) + const cleanupStrategy = strategy._s?.({ + element: options.element, + prefetch: () => finish('prefetch'), + }) + cleanupStrategyRef.current = cleanupStrategy + if (state.disposed) { + runHydrationStrategyCleanup(cleanupStrategy)?.() + } + }) +} + +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) + const when = marker.getAttribute(hydrateWhenAttribute) + if (!id || !when || when === '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 0000000000..f4250067d6 --- /dev/null +++ b/packages/start-client-core/src/hydration/types.ts @@ -0,0 +1,90 @@ +export type HydrationWhen = + | 'load' + | 'idle' + | 'visible' + | 'media' + | 'interaction' + | 'condition' + | 'never' + | 'dynamic' + +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 + prefetch?: () => void + delegated?: boolean +} + +export type HydrationStrategyTypes< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = { + when: TWhen + canPrefetch: TCanPrefetch +} + +export type HydrationStrategy< + TWhen extends HydrationWhen = HydrationWhen, + TCanPrefetch extends boolean = boolean, +> = { + _t?: TWhen + readonly '~types'?: HydrationStrategyTypes + _d?: () => boolean + _s?: (context: HydrationRuntimeContext) => void | (() => void) + _o?: (id: string) => void + _a?: () => HydrationMarkerAttributes | undefined +} + +export type HydrationPrefetchWhen = Exclude< + HydrationWhen, + 'condition' | 'never' | 'dynamic' +> + +export type HydrationPrefetchStrategy< + TWhen extends HydrationPrefetchWhen = HydrationPrefetchWhen, +> = HydrationStrategy + +export type HydrationPrefetchWaitReason = 'prefetch' | 'hydrate' | 'abort' + +export type HydrationPrefetchContext = { + element: Element | null + signal: AbortSignal + preload: () => Promise + waitFor: ( + strategy: HydrationPrefetchStrategy, + ) => Promise +} + +export type HydrationPrefetchFunction = ( + context: HydrationPrefetchContext, +) => void | Promise 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 0000000000..3c3391b1de --- /dev/null +++ b/packages/start-client-core/src/hydration/visible.ts @@ -0,0 +1,90 @@ +import type { HydrationPrefetchStrategy } from './types' + +const visibleType = 'visible' + +export type VisibleHydrationOptions = { + rootMargin?: string + threshold?: number | Array +} + +type VisibleObserverEntry = { + key: string + observer: IntersectionObserver + elements: Map void>> +} + +const observerRegistry = /* @__PURE__ */ new Map() + +function cleanupVisibleObserverEntry(observerEntry: VisibleObserverEntry) { + if (observerEntry.elements.size > 0) return + observerEntry.observer.disconnect() + observerRegistry.delete(observerEntry.key) +} + +/* @__NO_SIDE_EFFECTS__ */ +export function visible( + options: VisibleHydrationOptions = {}, +): HydrationPrefetchStrategy { + const rootMargin = options.rootMargin ?? '600px' + const threshold = options.threshold ?? 0 + + return { + _t: visibleType, + _s: ({ element, gate, prefetch }) => { + const callback = prefetch ?? gate!.resolve + + if (!element) { + callback() + return + } + + const key = `${rootMargin}|${ + Array.isArray(threshold) ? threshold.join(',') : String(threshold) + }` + let observerEntry = observerRegistry.get(key) + + if (!observerEntry) { + const entry: VisibleObserverEntry = { + key, + elements: new Map void>>(), + observer: new IntersectionObserver( + (entries) => { + for (const intersectingEntry of entries) { + if (!intersectingEntry.isIntersecting) continue + + const callbacks = entry.elements.get(intersectingEntry.target) + if (!callbacks) continue + + callbacks.forEach((callback) => callback()) + entry.elements.delete(intersectingEntry.target) + entry.observer.unobserve(intersectingEntry.target) + cleanupVisibleObserverEntry(entry) + } + }, + { rootMargin, threshold }, + ), + } + observerRegistry.set(key, entry) + observerEntry = entry + } + + let callbacks = observerEntry.elements.get(element) + if (!callbacks) { + callbacks = new Set() + observerEntry.elements.set(element, callbacks) + observerEntry.observer.observe(element) + } + callbacks.add(callback) + + return () => { + const currentCallbacks = observerEntry.elements.get(element) + currentCallbacks?.delete(callback) + if (currentCallbacks?.size === 0) { + observerEntry.elements.delete(element) + observerEntry.observer.unobserve(element) + } + cleanupVisibleObserverEntry(observerEntry) + } + }, + } +} diff --git a/packages/start-client-core/vite.config.ts b/packages/start-client-core/vite.config.ts index e893ba2c8d..c17997fc31 100644 --- a/packages/start-client-core/vite.config.ts +++ b/packages/start-client-core/vite.config.ts @@ -2,33 +2,42 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import packageJson from './package.json' -const config = defineConfig({ - test: { - typecheck: { enabled: true }, - name: packageJson.name, - watch: false, - environment: 'jsdom', - }, -}) - -export default mergeConfig( - config, - tanstackViteConfig({ - tsconfigPath: './tsconfig.build.json', - srcDir: './src', - entry: [ - './src/index.tsx', - './src/client/index.ts', - './src/client-rpc/index.ts', - './src/fake-entries/start.ts', - './src/fake-entries/router.ts', - './src/fake-entries/plugin-adapters.ts', - ], - cjs: false, - externalDeps: [ - '#tanstack-start-entry', - '#tanstack-router-entry', - '#tanstack-start-plugin-adapters', - ], - }), +export default defineConfig(({ command }) => + mergeConfig( + { + define: + command === 'build' + ? { + 'import.meta.hot': 'import.meta.hot', + } + : undefined, + test: { + typecheck: { enabled: true }, + name: packageJson.name, + watch: false, + environment: 'jsdom', + }, + }, + tanstackViteConfig({ + tsconfigPath: './tsconfig.build.json', + srcDir: './src', + entry: [ + './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', + ], + cjs: false, + externalDeps: [ + '#tanstack-start-entry', + '#tanstack-router-entry', + '#tanstack-start-plugin-adapters', + ], + }), + ), ) 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 0000000000..d88ddb60d0 --- /dev/null +++ b/packages/start-plugin-core/src/hydrate-when-transform.ts @@ -0,0 +1,1004 @@ +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, + collectIdentifiersFromPattern, + 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 { + CompileStartFrameworkOptions, + StartCompilerPlugin, + StartCompilerTransformResult, +} from './types' + +export class MissingHydrateSourceError extends Error { + constructor(id: string) { + super( + `Missing Hydrate source for virtual module ${id}. The parent module must be transformed before its Hydrate child chunk is loaded.`, + ) + } +} + +/** + * 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 sourceHash = crypto + .createHash('sha1') + .update(normalized) + .digest('hex') + .slice(0, 10) + + return (index: number) => { + return `${index.toString(36)}_${sourceHash}` + } +} + +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 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) + let boundaryIndex = -1 + if (splitId) { + const separatorIndex = splitId.indexOf('_') + if (separatorIndex > 0) { + const parsedIndex = Number.parseInt(splitId.slice(0, separatorIndex), 36) + if (Number.isInteger(parsedIndex)) { + boundaryIndex = parsedIndex + } + } + } + + return { + sourceId, + splitId, + boundaryIndex, + } +} + +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 isReferenceInsideAnyNode( + referencePath: babel.NodePath, + nodes: ReadonlySet, +) { + if (nodes.has(referencePath.node)) return true + return Boolean(referencePath.findParent((parent) => nodes.has(parent.node))) +} + +function stripBindingsOnlyReferencedBy( + path: babel.NodePath, + node: t.Node, + seen = new Set(), + preserve = new Set(), +) { + stripBindingsOnlyReferencedByNodes(path.scope, [node], seen, preserve) +} + +function stripBindingsOnlyReferencedByNodes( + scope: babel.NodePath['scope'], + nodes: ReadonlyArray, + seen = new Set(), + preserve = new Set(), +) { + const nodeSet = new Set(nodes) + const names = new Set() + nodes.forEach((node) => { + collectIdentifiersFromNode(node).forEach((name) => names.add(name)) + }) + + for (const name of names) { + if (seen.has(name)) continue + if (preserve.has(name)) continue + const binding = scope.getBinding(name) + if (!binding?.constant) continue + if ( + binding.path.findParent( + (parentPath) => + parentPath.isExportNamedDeclaration() || + parentPath.isExportDefaultDeclaration(), + ) + ) { + continue + } + if (binding.referencePaths.length === 0) continue + if ( + !binding.referencePaths.every((referencePath) => + isReferenceInsideAnyNode(referencePath, nodeSet), + ) + ) { + continue + } + + seen.add(name) + + const declarationPath = binding.path.isVariableDeclarator() + ? binding.path + : binding.path.findParent((parentPath) => + parentPath.isVariableDeclarator(), + ) + const patternHasExternalReferences = + declarationPath?.isVariableDeclarator() && + !t.isIdentifier(declarationPath.node.id) && + collectIdentifiersFromPattern(declarationPath.node.id).some( + (bindingName) => { + if (bindingName === binding.identifier.name) return false + + const siblingBinding = binding.scope.getBinding(bindingName) + return siblingBinding?.referencePaths.some( + (referencePath) => + !isReferenceInsideAnyNode(referencePath, nodeSet), + ) + }, + ) + + if (patternHasExternalReferences) { + continue + } + + const bindingNode = binding.path.node + if (t.isVariableDeclarator(bindingNode) && bindingNode.init) { + stripBindingsOnlyReferencedByNodes( + binding.scope, + [bindingNode.init], + seen, + preserve, + ) + } else if ( + t.isFunctionDeclaration(bindingNode) || + t.isClassDeclaration(bindingNode) + ) { + stripBindingsOnlyReferencedByNodes( + binding.scope, + [bindingNode], + seen, + preserve, + ) + } + + if (binding.path.isVariableDeclarator()) { + const declarationPath = binding.path.parentPath + if ( + declarationPath.isVariableDeclaration() && + declarationPath.node.declarations.length === 1 + ) { + declarationPath.remove() + continue + } + + binding.path.remove() + continue + } + + 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() + continue + } + + binding.path.remove() + continue + } + + binding.path.remove() + } +} + +function getSingleUseObjectExpressionBinding( + path: babel.NodePath, + identifier: t.Identifier, +) { + const binding = path.scope.getBinding(identifier.name) + if (!binding?.constant) return undefined + if (binding.referencePaths.length !== 1) return undefined + if (binding.referencePaths[0]?.node !== identifier) return undefined + if (!binding.path.isVariableDeclarator()) return undefined + const init = binding.path.node.init + return t.isObjectExpression(init) ? init : undefined +} + +function objectExpressionMayHaveProperty( + node: t.ObjectExpression, + name: string, +) { + return node.properties.some((property) => { + if (t.isSpreadElement(property)) return true + if (!t.isObjectMethod(property) && !t.isObjectProperty(property)) { + return true + } + if (property.computed) return true + return isObjectPropertyName(property, name) + }) +} + +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 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 + } +}) { + const { path } = options + const capturedNames = options.collectCaptured ? new Set() : undefined + const nestedHydrate = options.nestedHydrate + let nestedBoundaryCount = 0 + + 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 rootVisitors = { + JSXOpeningElement(openingPath: babel.NodePath) { + if (openingPath.node === path.node.openingElement) { + openingPath.skip() + } + }, + JSXClosingElement(closingPath: babel.NodePath) { + closingPath.skip() + }, + } + + 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++ + }, + } + : {} + + const captureVisitors = capturedNames + ? { + Identifier(identifierPath: babel.NodePath) { + 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.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({ + ...rootVisitors, + ...validateVisitors, + ...nestedHydrateVisitors, + ...captureVisitors, + }) + + return { + captured: capturedNames ? [...capturedNames].sort() : [], + nestedBoundaryCount, + } +} + +function getHydrateImport( + ast: t.File, + framework: CompileStartFrameworkOptions, +) { + const hydrateImportSource = `@tanstack/${framework}-start` + + for (const node of ast.program.body) { + if (!t.isImportDeclaration(node)) continue + if (node.source.value !== hydrateImportSource) continue + + for (const specifier of node.specifiers) { + if ( + t.isImportSpecifier(specifier) && + t.isIdentifier(specifier.imported, { name: 'Hydrate' }) + ) { + return { + hydrateLocalName: specifier.local.name, + } + } + } + } + + return undefined +} + +function getMeaningfulChildren( + children: Array, +) { + return children.filter( + (child) => !(t.isJSXText(child) && child.value.trim() === ''), + ) +} + +function transformHydrateAst(options: { + ast: t.File + code: string + id: string + root: string + env: 'client' | 'server' + framework: CompileStartFrameworkOptions + indexOffset?: number +}) { + if (!options.code.includes('Hydrate')) return null + + const hydrateImport = getHydrateImport(options.ast, options.framework) + 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, + extractedChildNodes: [] as Array, + capturedNames: new Set(), + } + let lazyIdent: t.Identifier | undefined + + babel.traverse(options.ast, { + Program(programPath) { + programPath.traverse({ + JSXElement(path) { + if (getJSXElementName(path.node) !== localName) return + + if (options.env === 'server') { + path.node.openingElement.attributes = + path.node.openingElement.attributes.filter((item) => { + if ( + t.isJSXAttribute(item) && + t.isJSXIdentifier(item.name, { name: 'fallback' }) + ) { + if (item.value) { + stripBindingsOnlyReferencedBy(path, item.value) + } + state.modified = true + return false + } + + if ( + t.isJSXSpreadAttribute(item) && + t.isObjectExpression(item.argument) + ) { + if ( + stripObjectExpressionProperty( + path, + item.argument, + 'fallback', + ) + ) { + state.modified = true + } + return item.argument.properties.length > 0 + } + + if ( + t.isJSXSpreadAttribute(item) && + t.isIdentifier(item.argument) + ) { + const init = getSingleUseObjectExpressionBinding( + path, + item.argument, + ) + if ( + init && + stripObjectExpressionProperty(path, init, 'fallback') + ) { + state.modified = true + } + } + + return 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, + }, + } + : {}), + }) + + const index = nextBoundaryIndex + nextBoundaryIndex += 1 + boundaryInspection.nestedBoundaryCount + const id = getBoundaryId(index) + const exportName = `H${index}` + + const existingHydrateId = getJSXAttribute( + path.node.openingElement, + 'h', + ) + if (existingHydrateId) { + existingHydrateId.value = t.stringLiteral(id) + } else { + path.node.openingElement.attributes.push( + t.jsxAttribute(t.jsxIdentifier('h'), t.stringLiteral(id)), + ) + } + state.modified = true + + if (options.env === 'server') return + + const needsPreloadProp = path.node.openingElement.attributes.some( + (attribute) => { + if (t.isJSXAttribute(attribute)) { + return t.isJSXIdentifier(attribute.name, { name: 'prefetch' }) + } + + if (t.isJSXSpreadAttribute(attribute)) { + if (t.isObjectExpression(attribute.argument)) { + return objectExpressionMayHaveProperty( + attribute.argument, + 'prefetch', + ) + } + + if (t.isIdentifier(attribute.argument)) { + const init = getSingleUseObjectExpressionBinding( + path, + attribute.argument, + ) + return init + ? objectExpressionMayHaveProperty(init, 'prefetch') + : true + } + + return true + } + + return false + }, + ) + const childReferenceNodes = getMeaningfulChildren(path.node.children) + + state.extractedChildNodes.push(...childReferenceNodes) + boundaryInspection.captured.forEach((name) => { + state.capturedNames.add(name) + }) + + if (!lazyIdent) { + lazyIdent = + programPath.scope.generateUidIdentifier('lazyRouteComponent') + programPath.unshiftContainer('body', [ + t.importDeclaration( + [ + t.importSpecifier( + lazyIdent, + t.identifier('lazyRouteComponent'), + ), + ], + t.stringLiteral(`@tanstack/${options.framework}-router`), + ), + ]) + } + + const importIdParams = new URLSearchParams() + importIdParams.set(tssHydrate, id) + const componentIdent = + programPath.scope.generateUidIdentifier(exportName) + const declarations = [ + t.variableDeclarator( + componentIdent, + t.callExpression(lazyIdent, [ + t.arrowFunctionExpression( + [], + t.callExpression(t.import(), [ + t.stringLiteral(`${sourceId}?${importIdParams.toString()}`), + ]), + ), + t.stringLiteral(exportName), + ]), + ), + ] + + let preloadIdent: t.Identifier | undefined + if (needsPreloadProp) { + preloadIdent = programPath.scope.generateUidIdentifier( + `${exportName}_preload`, + ) + declarations.push( + t.variableDeclarator( + preloadIdent, + t.memberExpression(componentIdent, t.identifier('preload')), + ), + ) + } + + programPath.unshiftContainer('body', [ + t.variableDeclaration('const', declarations), + ]) + if (preloadIdent) { + path.node.openingElement.attributes.push( + t.jsxAttribute( + t.jsxIdentifier('p'), + 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.extractedChildNodes.length > 0) { + stripBindingsOnlyReferencedByNodes( + programPath.scope, + state.extractedChildNodes, + new Set(), + state.capturedNames, + ) + } + + programPath.skip() + }, + }) + + if (!state.modified) return null + + return true +} + +function loadHydrateVirtualModule(options: { + id: string + root: string + code: string + framework: CompileStartFrameworkOptions +}) { + 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, options.framework) + 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 = `H${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 meaningfulChildren = getMeaningfulChildren(children) + let returnExpression: t.Expression | t.JSXElement | t.JSXFragment = + t.nullLiteral() + + if (meaningfulChildren.length === 1) { + const child = meaningfulChildren[0]! + if (t.isJSXExpressionContainer(child)) { + returnExpression = t.isJSXEmptyExpression(child.expression) + ? t.nullLiteral() + : child.expression + } else if (t.isJSXElement(child) || t.isJSXFragment(child)) { + returnExpression = child + } else if (t.isJSXText(child)) { + returnExpression = t.stringLiteral(child.value) + } + } else if (meaningfulChildren.length > 1) { + returnExpression = t.jsxFragment( + t.jsxOpeningFragment(), + t.jsxClosingFragment(), + children, + ) + } + for (const name of collectIdentifiersFromNode(returnExpression)) { + 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), + targetCaptured.length > 0 + ? [ + t.objectPattern( + targetCaptured.map((name) => + t.objectProperty( + t.identifier(name), + t.identifier(name), + false, + true, + ), + ), + ), + ] + : [], + t.blockStatement([t.returnStatement(returnExpression)]), + ), + ), + ) + + deadCodeElimination(ast, refIdents) + stripUnreferencedTopLevelExpressionStatements(ast) + + const result = generateFromAst(ast, { + sourceMaps: true, + sourceFileName: options.id, + filename: options.id, + }) + return result +} + +export function createHydrateCompilerPlugin(): StartCompilerPlugin { + type SourceEntry = { + code: string + framework: CompileStartFrameworkOptions + virtualModules: Map + } + + 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, + framework: CompileStartFrameworkOptions, + ) => { + const sourceId = cleanId(id) + const sources = getEnvironmentSources(envName) + const existing = sources.get(sourceId) + if (existing?.code === code && existing.framework === framework) { + return existing + } + + const entry = { + code, + framework, + virtualModules: new Map(), + } + sources.set(sourceId, entry) + return entry + } + + const getSourceEntry = (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, + framework: context.framework, + indexOffset, + }) + + if (result && virtualModule.boundaryIndex < 0) { + setSource(context.envName, context.id, context.code, context.framework) + } + + return !!result + }, + loadVirtualModule(context) { + const virtualModule = parseHydrateVirtualId(context.id) + if (!virtualModule.splitId || virtualModule.boundaryIndex < 0) { + return null + } + + const existingSourceEntry = getSourceEntry( + context.envName, + virtualModule.sourceId, + ) + const sourceEntry = + context.code === undefined + ? existingSourceEntry + : setSource( + context.envName, + virtualModule.sourceId, + context.code, + existingSourceEntry?.framework ?? + (context.code.includes('@tanstack/solid-start') + ? 'solid' + : 'react'), + ) + + if (!sourceEntry) { + throw new MissingHydrateSourceError(context.id) + } + + if (sourceEntry.virtualModules.has(context.id)) { + return sourceEntry.virtualModules.get(context.id)! + } + + const result = loadHydrateVirtualModule({ + code: sourceEntry.code, + id: context.id, + root: context.root, + framework: sourceEntry.framework, + }) + sourceEntry.virtualModules.set(context.id, result) + return result + }, + 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 0000000000..b79c20ffd3 --- /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 b349b68040..60244fdca9 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 066d5b2867..d0ef77e34b 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 e742a75d41..458c467e4d 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -1,8 +1,8 @@ -/* eslint-disable import/no-commonjs */ import crypto from 'node:crypto' import * as t from '@babel/types' import { deadCodeElimination, + extractModuleInfoFromAst, findReferencedIdentifiers, generateFromAst, parseAst, @@ -21,28 +21,26 @@ import type { RewriteCandidate, ServerFn, } from './types' +import type { ModuleInfoBinding } from '@tanstack/router-utils' import type { CompileStartFrameworkOptions, StartCompilerEnvironment, StartCompilerImportTransform, + StartCompilerPlugin, + StartCompilerTransformResult, } from '../types' -type Binding = - | { - type: 'import' - source: string - importedName: string - resolvedKind?: Kind - } - | { - type: 'var' - init: t.Expression | null - resolvedKind?: Kind - } +type Binding = ModuleInfoBinding & { + resolvedKind?: Kind +} type ImportBinding = Extract type Kind = 'None' | `Root` | `Builder` | LookupKind +type ParsedAst = ReturnType +type StartCompilerAstPlugin = StartCompilerPlugin & { + transformAst: NonNullable +} export type BuiltInLookupKind = | 'ServerFn' @@ -86,11 +84,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< @@ -219,13 +234,16 @@ export function detectKindsInCode( const validForEnv = getLookupKindsForEnv(env, opts) for (const kind of AllBuiltInLookupKinds) { - if (validForEnv.has(kind) && KindDetectionPatterns[kind].test(code)) { + const pattern = KindDetectionPatterns[kind] + pattern.lastIndex = 0 + if (validForEnv.has(kind) && pattern.test(code)) { detected.add(kind) } } for (const transform of opts?.compilerTransforms ?? []) { if (!isCompilerTransformEnabledForEnv(transform, env)) continue + transform.detect.lastIndex = 0 if (transform.detect.test(code)) { detected.add(getExternalLookupKind(transform)) } @@ -464,6 +482,7 @@ export class StartCompiler { StartCompilerImportTransform >() private externalLookupSetup = new Map() + private compilerPlugins: Array private externalDirectCallKindsBySource = new Map< string, Map @@ -517,6 +536,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. @@ -527,6 +547,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 @@ -775,100 +799,13 @@ export class StartCompiler { ast: ReturnType, id: string, ): ModuleInfo { - const bindings = new Map() - const exports = new Map() - const reExportAllSources: Array = [] - - // we are only interested in top-level bindings, hence we don't traverse the AST - // instead we only iterate over the program body - for (const node of ast.program.body) { - if (t.isImportDeclaration(node)) { - const source = node.source.value - for (const s of node.specifiers) { - if (t.isImportSpecifier(s)) { - const importedName = t.isIdentifier(s.imported) - ? s.imported.name - : s.imported.value - bindings.set(s.local.name, { type: 'import', source, importedName }) - } else if (t.isImportDefaultSpecifier(s)) { - bindings.set(s.local.name, { - type: 'import', - source, - importedName: 'default', - }) - } else if (t.isImportNamespaceSpecifier(s)) { - bindings.set(s.local.name, { - type: 'import', - source, - importedName: '*', - }) - } - } - } else if (t.isVariableDeclaration(node)) { - for (const decl of node.declarations) { - if (t.isIdentifier(decl.id)) { - bindings.set(decl.id.name, { - type: 'var', - init: decl.init ?? null, - }) - } - } - } else if (t.isExportNamedDeclaration(node)) { - // export const foo = ... - if (node.declaration) { - if (t.isVariableDeclaration(node.declaration)) { - for (const d of node.declaration.declarations) { - if (t.isIdentifier(d.id)) { - exports.set(d.id.name, d.id.name) - bindings.set(d.id.name, { type: 'var', init: d.init ?? null }) - } - } - } - } - for (const sp of node.specifiers) { - if (t.isExportNamespaceSpecifier(sp)) { - exports.set(sp.exported.name, sp.exported.name) - } - // export { local as exported } - else if (t.isExportSpecifier(sp)) { - const local = sp.local.name - const exported = t.isIdentifier(sp.exported) - ? sp.exported.name - : sp.exported.value - exports.set(exported, local) - - // When re-exporting from another module (export { foo } from './module'), - // create an import binding so the server function can be resolved - if (node.source) { - bindings.set(local, { - type: 'import', - source: node.source.value, - importedName: local, - }) - } - } - } - } else if (t.isExportDefaultDeclaration(node)) { - const d = node.declaration - if (t.isIdentifier(d)) { - exports.set('default', d.name) - } else { - const synth = '__default_export__' - bindings.set(synth, { type: 'var', init: d as t.Expression }) - exports.set('default', synth) - } - } else if (t.isExportAllDeclaration(node)) { - // Handle `export * from './module'` syntax - // Track the source so we can look up exports from it when needed - reExportAllSources.push(node.source.value) - } - } + const extracted = extractModuleInfoFromAst(ast) const info: ModuleInfo = { id, - bindings, - exports, - reExportAllSources, + bindings: new Map(extracted.bindings), + exports: extracted.exports, + reExportAllSources: extracted.reExportAllSources, } this.moduleCache.set(id, info) return info @@ -889,13 +826,30 @@ export class StartCompiler { } public invalidateModule(id: string) { - const normalizedId = cleanId(id) - let hasCachedModule = false + return this.invalidateModules([id]).size > 0 + } + + public invalidateModules(ids: Iterable): Set { + const normalizedIds = new Set() + + for (const id of ids) { + normalizedIds.add(cleanId(id)) + + for (const plugin of this.compilerPlugins) { + plugin.invalidateModule?.({ id, envName: this.options.envName }) + } + } + + const deletedModuleIds = new Set() + if (normalizedIds.size === 0) { + return deletedModuleIds + } for (const moduleId of Array.from(this.moduleCache.keys())) { - if (cleanId(moduleId) === normalizedId) { + const normalizedModuleId = cleanId(moduleId) + if (normalizedIds.has(normalizedModuleId)) { this.moduleCache.delete(moduleId) - hasCachedModule = true + deletedModuleIds.add(normalizedModuleId) } } @@ -915,14 +869,20 @@ export class StartCompiler { this.resolveIdCache.clear() this.exportResolutionCache.clear() - return hasCachedModule + return deletedModuleIds } - public async getTransitiveImporters(id: string): Promise> { + public async getTransitiveImporters( + ids: string | Iterable, + ): Promise> { const discoveredImporters = new Set() - const pendingTargets = [cleanId(id)] + const pendingTargets = + typeof ids === 'string' + ? [cleanId(ids)] + : Array.from(ids, (id) => cleanId(id)) const visitedTargets = new Set() const resolveCache = new Map>() + const importersByTarget = new Map>() const resolveSource = (source: string, importer: string) => { const cacheKey = `${importer}::${source}` @@ -936,49 +896,51 @@ export class StartCompiler { return resolved } - while (pendingTargets.length > 0) { - const targetId = pendingTargets.pop()! - - if (visitedTargets.has(targetId)) { - continue - } + await Promise.all( + Array.from(this.moduleCache.values()).map(async (moduleInfo) => { + if (this.knownRootImports.has(moduleInfo.id)) { + return + } - visitedTargets.add(targetId) + const moduleId = cleanId(moduleInfo.id) + const importSources = new Set(moduleInfo.reExportAllSources) - const importerIds = await Promise.all( - Array.from(this.moduleCache.values()).map(async (moduleInfo) => { - if (this.knownRootImports.has(moduleInfo.id)) { - return null + for (const binding of moduleInfo.bindings.values()) { + if (binding.type === 'import') { + importSources.add(binding.source) } + } - const moduleId = cleanId(moduleInfo.id) - - if (moduleId === targetId) { - return null - } + await Promise.all( + Array.from(importSources, async (source) => { + const resolved = await resolveSource(source, moduleInfo.id) + if (!resolved) return - const importSources = new Set(moduleInfo.reExportAllSources) + const targetId = cleanId(resolved) + if (targetId === moduleId) return - for (const binding of moduleInfo.bindings.values()) { - if (binding.type === 'import') { - importSources.add(binding.source) + let importers = importersByTarget.get(targetId) + if (!importers) { + importers = new Set() + importersByTarget.set(targetId, importers) } - } + importers.add(moduleId) + }), + ) + }), + ) - for (const source of importSources) { - const resolved = await resolveSource(source, moduleInfo.id) + while (pendingTargets.length > 0) { + const targetId = pendingTargets.pop()! - if (resolved && cleanId(resolved) === targetId) { - return moduleId - } - } + if (visitedTargets.has(targetId)) { + continue + } - return null - }), - ) + visitedTargets.add(targetId) - for (const importerId of importerIds) { - if (!importerId || discoveredImporters.has(importerId)) { + for (const importerId of importersByTarget.get(targetId) ?? []) { + if (discoveredImporters.has(importerId)) { continue } @@ -1011,408 +973,435 @@ 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 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) - + const astTransformPlugins = this.getAstTransformPluginsForCode(code) // 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, - ) + const ast = this.ingestModule({ code, id, parserFilename }).ast + let astHasChanges = false + + 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) + + // 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 + } - // Filter to valid candidates - const validCandidates = resolvedCandidates.filter(({ path, kind }) => { - if ( - !this.validLookupKinds.has(kind as Exclude) - ) { - return false + // 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, + }) + } 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' }) + } - this.runExternalTransforms('post', candidatesByKind, context) + deadCodeElimination(ast, refIdents) + astHasChanges = true + } - // 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 (astTransformPlugins.length > 0) { + astHasChanges = + this.runAstTransforms({ + ast, + code, + id, + transforms: astTransformPlugins, + }) || astHasChanges } - deadCodeElimination(ast, refIdents) + return astHasChanges ? this.generateResultFromAst(ast, code, id) : null + } + private generateResultFromAst( + ast: ParsedAst, + sourceCode: string, + id: string, + ): StartCompilerTransformResult { const result = generateFromAst(ast, { sourceMaps: true, sourceFileName: id, @@ -1420,17 +1409,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< @@ -1608,8 +1646,8 @@ export class StartCompiler { return 'None' } - for (const [source, exports] of this.knownRootImports) { - const kind = exports.get(binding.importedName) + for (const [source, rootExports] of this.knownRootImports) { + const kind = rootExports.get(binding.importedName) if (!kind) { continue } diff --git a/packages/start-plugin-core/src/start-compiler/config.ts b/packages/start-plugin-core/src/start-compiler/config.ts index e9d7d95e5e..387343bad9 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 e1da89d7f2..c225347dea 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 a3c991c4c7..6f27f37d4d 100644 --- a/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts +++ b/packages/start-plugin-core/src/start-manifest-plugin/manifestBuilder.ts @@ -484,10 +484,35 @@ export function buildRouteManifestRoutes(options: { getChunkCssAssets, getChunkPreloads: options.assetResolvers.getChunkPreloads, }) + + if (routeId !== rootRouteId) { + mergeReachableHydrationChunkData({ + route: targetRoute, + chunk, + chunksByFileName: options.chunksByFileName, + getChunkCssAssets, + }) + } } } const rootRoute = (routes[rootRouteId] = routes[rootRouteId] || {}) + const rootRouteTreeRoute = options.routeTreeRoutes[rootRouteId] + const rootRouteChunks = rootRouteTreeRoute?.filePath + ? options.routeChunksByFilePath.get(rootRouteTreeRoute.filePath) + : undefined + + if (rootRouteChunks) { + for (const chunk of rootRouteChunks) { + mergeReachableHydrationChunkData({ + route: rootRoute, + chunk, + chunksByFileName: options.chunksByFileName, + getChunkCssAssets, + }) + } + } + mergeRouteChunkData({ route: rootRoute, chunk: options.entryChunk, @@ -517,6 +542,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 bc76726875..f0acdbfe35 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 698ab5ae1a..7a2ddd9ff6 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,18 @@ 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 { + MissingHydrateSourceError, + createHydrateCompilerPlugin, +} from '../../hydrate-when-transform' import { resolveViteId } from '../../utils' import { createViteDevServerFnModuleSpecifierEncoder, @@ -23,12 +29,13 @@ import { mergeHotUpdateModules } from './hot-update' import type { CompileStartFrameworkOptions, StartCompilerImportTransform, + StartCompilerPlugin, } from '../../types' import type { GenerateFunctionIdFnOptional, ServerFn, } from '../../start-compiler/types' -import type { EnvironmentModuleNode, PluginOption } from 'vite' +import type { Environment, EnvironmentModuleNode, PluginOption } from 'vite' // Re-export from shared constants for backwards compatibility export { SERVER_FN_LOOKUP } @@ -46,6 +53,32 @@ type ModuleInvalidationEnvironment = { } } +type ViteModuleLoadOptions = { + devId?: string + load: (options: { id: string }) => Promise<{ code?: string | null } | null> + error: (message: string) => never +} + +async function loadViteModuleFromEnvironment( + environment: Environment, + id: string, + opts: ViteModuleLoadOptions, +): Promise { + if (environment.mode === 'build') { + const loaded = await opts.load({ id }) + return loaded?.code ?? '' + } + + if (environment.mode === 'dev') { + await environment.transformRequest(opts.devId ?? id) + return undefined + } + + opts.error( + `could not load module ${id}: unknown environment mode ${environment.mode}`, + ) +} + function invalidateMatchingFileModules( environment: ModuleInvalidationEnvironment, ids: Iterable, @@ -104,6 +137,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 +222,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 +234,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 +280,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}`, @@ -262,6 +325,7 @@ export function startCompilerPlugin( providerEnvName: opts.providerEnvName, generateFunctionId: opts.generateFunctionId, compilerTransforms, + compilerPlugins, serverFnProviderModuleDirectives, onServerFnsById, getKnownServerFns: () => serverFnsById, @@ -270,23 +334,18 @@ export function startCompilerPlugin( ? createViteDevServerFnModuleSpecifierEncoder(root) : undefined, loadModule: async (id: string) => { - if (mode === 'build') { - const loaded = await this.load({ id }) - const code = loaded.code ?? '' - + const code = await loadViteModuleFromEnvironment( + this.environment, + id, + { + load: (options) => this.load(options), + error: (message) => this.error(message), + devId: `${id}?${SERVER_FN_LOOKUP}`, + }, + ) + if (code !== undefined) { compiler!.ingestModule({ code, id }) - return } - - if (this.environment.mode !== 'dev') { - this.error( - `could not load module ${id}: unknown environment mode ${this.environment.mode}`, - ) - } - - await this.environment.transformRequest( - `${id}?${SERVER_FN_LOOKUP}`, - ) }, resolveId: async (source: string, importer?: string) => { @@ -315,6 +374,7 @@ export function startCompilerPlugin( code, detectedKinds, }) + return result }, }, @@ -324,17 +384,22 @@ export function startCompilerPlugin( const idsToInvalidate = new Set() const transitiveCompilerImportersToInvalidate = new Set() const importerModulesToInvalidate = new Set() + const changedIds: Array = [] ctx.modules.forEach((m) => { if (m.id) { idsToInvalidate.add(m.id) - const deleted = compiler?.invalidateModule(m.id) + changedIds.push(m.id) + } + }) - if (deleted) { + const deletedIds = compiler?.invalidateModules(changedIds) ?? new Set() + + ctx.modules.forEach((m) => { + if (m.id) { + if (deletedIds.has(cleanId(m.id))) { transitiveCompilerImportersToInvalidate.add(cleanId(m.id)) - } - if (deleted) { m.importers.forEach((importer) => { if (importer.id) { idsToInvalidate.add(importer.id) @@ -349,54 +414,54 @@ export function startCompilerPlugin( }) const finishHotUpdate = async () => { - if (environment.type === 'server' && compiler) { - const pendingImporters = [ - ...transitiveCompilerImportersToInvalidate, - ] - const seenImporters = new Set(pendingImporters) - - while (pendingImporters.length > 0) { - const importerId = pendingImporters.pop()! - const nestedImporters = - await compiler.getTransitiveImporters(importerId) - - for (const nestedImporterId of nestedImporters) { - if (seenImporters.has(nestedImporterId)) { - continue - } - - seenImporters.add(nestedImporterId) - pendingImporters.push(nestedImporterId) - } + if ( + environment.type === 'server' && + compiler && + transitiveCompilerImportersToInvalidate.size > 0 + ) { + const seenImporters = new Set( + transitiveCompilerImportersToInvalidate, + ) + const nestedImporters = + await compiler.getTransitiveImporters(seenImporters) + + for (const nestedImporterId of nestedImporters) { + seenImporters.add(nestedImporterId) } for (const importerId of seenImporters) { idsToInvalidate.add(importerId) - compiler.invalidateModule(importerId) } + compiler.invalidateModules(seenImporters) } 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) const providerIdsToInvalidate = getServerFnProviderIds(idsToInvalidate) - for (const providerId of providerIdsToInvalidate) { - compiler?.invalidateModule(providerId) - } + compiler?.invalidateModules(providerIdsToInvalidate) const providerModules = invalidateServerFnProviderModules( this.environment, [...idsToInvalidate, ...providerIdsToInvalidate], ) - return mergeHotUpdateModules(ctx.modules, providerModules) + return mergeHotUpdateModules(ctx.modules, [ + ...compilerVirtualModules, + ...providerModules, + ]) } return finishHotUpdate() @@ -423,6 +488,45 @@ export function startCompilerPlugin( }, }, }, + { + name: 'tanstack-start-core:compiler-virtual-module', + enforce: 'pre', + load: { + filter: { + id: compilerVirtualModuleIdPattern ?? /$^/, + }, + async handler(id) { + const environment = environmentByName.get(this.environment.name) + if (!environment || !compilerVirtualModuleIdPattern) { + return null + } + + const loadVirtualModule = () => + loadCompilerVirtualModule(compilerPlugins, { + id, + root, + env: environment.type, + envName: this.environment.name, + }) + + try { + return loadVirtualModule() + } catch (error) { + if (!(error instanceof MissingHydrateSourceError)) { + throw error + } + } + + const sourceId = cleanId(id) + await loadViteModuleFromEnvironment(this.environment, sourceId, { + load: (options) => this.load(options), + error: (message) => this.error(message), + }) + + return loadVirtualModule() + }, + }, + }, // Validate server function ID in dev mode { name: 'tanstack-start-core:validate-server-fn-id', @@ -451,7 +555,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 8ac7915ff0..c14609bc6a 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/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index e5c99fd257..94780d140b 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -185,6 +185,29 @@ describe('detectKindsInCode', () => { new Set(['ClientOnlyFn']), ) }) + + test('resets external transform regex state between detections', () => { + const compilerTransforms: Array = [ + { + name: 'global-detect', + environment: 'server', + imports: [{ libName: '@example/runtime', rootExport: 'renderThing' }], + detect: /\brenderThing\b/g, + transform: () => {}, + }, + ] + const code = ` + import { renderThing } from '@example/runtime' + renderThing() + ` + + expect(detectKindsInCode(code, 'server', { compilerTransforms })).toEqual( + new Set(['External:global-detect']), + ) + expect(detectKindsInCode(code, 'server', { compilerTransforms })).toEqual( + new Set(['External:global-detect']), + ) + }) }) describe('detects multiple kinds in same file', () => { @@ -611,6 +634,53 @@ test('ingestModule handles empty code gracefully', () => { }).not.toThrow() }) +test('compile caches modules without detected candidates for transitive importer traversal', async () => { + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [], + getKnownServerFns: () => ({}), + loadModule: async () => {}, + resolveId: async (source, importer) => { + if (source === './leaf' && importer === '/src/plain.ts') { + return '/src/leaf.ts' + } + + if (source === './plain' && importer === '/src/parent.ts') { + return '/src/plain.ts' + } + + return null + }, + }) + + await compiler.compile({ + id: '/src/plain.ts', + code: ` + import { value } from './leaf' + export const plain = value + `, + detectedKinds: new Set(), + }) + + await compiler.compile({ + id: '/src/parent.ts', + code: ` + import { plain } from './plain' + export const parent = plain + `, + detectedKinds: new Set(), + }) + + expect(await compiler.getTransitiveImporters('/src/leaf.ts')).toEqual( + new Set(['/src/plain.ts', '/src/parent.ts']), + ) + expect(await compiler.getTransitiveImporters(['/src/leaf.ts'])).toEqual( + new Set(['/src/plain.ts', '/src/parent.ts']), + ) +}) + describe('calling result of createServerOnlyFn/createClientOnlyFn', () => { // This tests the fix for https://github.com/TanStack/router/issues/6643 // When a file has both createServerFn and createServerOnlyFn, and the result 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 0000000000..13e1d909bd --- /dev/null +++ b/packages/start-plugin-core/tests/hydrate-when-transform.test.ts @@ -0,0 +1,618 @@ +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' +import type { CompileStartFrameworkOptions } from '../src/types' + +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 separatorIndex = boundaryId?.indexOf('_') ?? -1 + const index = + boundaryId && separatorIndex > 0 + ? Number.parseInt(boundaryId.slice(0, separatorIndex), 36) + : Number.NaN + + 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) + return `${file}?${params.toString()}` +} + +function withSourceHash(id: string, sourceHash: string) { + const separatorIndex = id.indexOf('_') + return `${id.slice(0, separatorIndex + 1)}${sourceHash}` +} + +function compileHydrate(options: { + code: string + id: string + root: string + env: 'client' | 'server' + envName?: string + framework?: CompileStartFrameworkOptions + 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: options.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('lazyRouteComponent') + expect(result?.code).toContain('tss-hydrate=') + expect(result?.code).not.toContain('tss-hydrate-index') + expect(result?.code).not.toContain('createElement') + expect(result?.code).toContain('h=') + expect(result?.code).not.toContain('p=') + }) + + test('uses the Solid Router 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', + framework: 'solid', + }) + + expect(result?.code).toContain( + 'lazyRouteComponent } from "@tanstack/solid-router"', + ) + expect(result?.code).toContain('tss-hydrate=') + expect(result?.code).toContain('h=') + expect(result?.code).not.toContain('p=') + }) + + 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('h=') + 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('H1') + 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(['H0', 'H2']) + + 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('H1') + 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 H0({') + 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 source hash does not match', () => { + 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, { + id: withSourceHash(firstPass!.boundaries[0]!.id, 'mismatch'), + index: firstPass!.boundaries[0]!.index, + }) + + expect( + new URLSearchParams(mismatchedId.split('?')[1]).get('tss-hydrate'), + ).toMatch(/_mismatch$/) + 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 0000000000..a2560a6197 --- /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 0000000000..1e89bb7303 --- /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 0000000000..63b91b0239 --- /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 0000000000..5f7b3df606 --- /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 0000000000..2d121539fe --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/hydrateWhen.test.ts @@ -0,0 +1,241 @@ +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) +} + +function normalizeSnapshotCode(code: string) { + return code.split(fixtureRoot).join('') +} + +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 separatorIndex = boundaryId?.indexOf('_') ?? -1 + const index = + boundaryId && separatorIndex > 0 + ? Number.parseInt(boundaryId.slice(0, separatorIndex), 36) + : Number.NaN + + 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) + 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( + normalizeSnapshotCode(result?.code ?? 'no-transform'), + ).toMatchFileSnapshot(`./snapshots/client/${filename}`) + }) + + test(`should compile ${filename} for server`, async () => { + const result = compile({ env: 'server', code, id }) + + await expect( + normalizeSnapshotCode(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(['H0', 'H2']) + + for (const boundary of firstPass!.boundaries) { + const virtualId = virtualHydrateId(id, boundary) + const loaded = loadVirtualHydrateModule({ + code, + id: virtualId, + root: fixtureRoot, + }) + + await expect( + normalizeSnapshotCode(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( + normalizeSnapshotCode(nestedPass?.code ?? 'no-transform'), + ).toMatchFileSnapshot(`./snapshots/virtual/${filename}.H0.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 0000000000..c977cd70a2 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenBasic.tsx @@ -0,0 +1,18 @@ +const _H = _lazyRouteComponent(() => import("/hydrateWhenBasic.tsx?tss-hydrate=0_3cf0187f82"), "H0"), + _H0_preload = _H.preload; +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +import { FallbackPane } from './widgets'; +export function Page() { + return
+ } h="0_3cf0187f82" p={_H0_preload}> + {<_H />} + +
; +} \ 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 0000000000..8a68575d69 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenMultiple.tsx @@ -0,0 +1,19 @@ +const _H3 = _lazyRouteComponent(() => import("/hydrateWhenMultiple.tsx?tss-hydrate=2_21aa371e0f"), "H2"); +const _H2 = _lazyRouteComponent(() => import("/hydrateWhenMultiple.tsx?tss-hydrate=1_21aa371e0f"), "H1"); +const _H = _lazyRouteComponent(() => import("/hydrateWhenMultiple.tsx?tss-hydrate=0_21aa371e0f"), "H0"); +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate } from '@tanstack/react-start'; +import { load, media, visible } from '@tanstack/react-start/hydration'; +export function Page() { + return <> + + {<_H />} + + + {<_H2 />} + + + {<_H3 />} + + ; +} \ 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 0000000000..97c3dba1a4 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNested.tsx @@ -0,0 +1,16 @@ +const _H2 = _lazyRouteComponent(() => import("/hydrateWhenNested.tsx?tss-hydrate=2_466696e41d"), "H2"); +const _H = _lazyRouteComponent(() => import("/hydrateWhenNested.tsx?tss-hydrate=0_466696e41d"), "H0"); +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +const unused = 'remove me from virtual modules'; +export function Page() { + return <> + + {<_H />} + + + {<_H2 />} + + ; +} \ 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 0000000000..426e83fa73 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenNever.tsx @@ -0,0 +1,9 @@ +const _H = _lazyRouteComponent(() => import("/hydrateWhenNever.tsx?tss-hydrate=0_b752509d76"), "H0"); +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate } from '@tanstack/react-start'; +import { never } from '@tanstack/react-start/hydration'; +export function Page() { + return + {<_H />} + ; +} \ 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 0000000000..b35c36ab1b --- /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 0000000000..b35c36ab1b --- /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 0000000000..1bbe2337d6 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenObjectFallback.tsx @@ -0,0 +1,26 @@ +const _H3 = _lazyRouteComponent(() => import("/hydrateWhenObjectFallback.tsx?tss-hydrate=2_7f4dc3aa80"), "H2"); +const _H2 = _lazyRouteComponent(() => import("/hydrateWhenObjectFallback.tsx?tss-hydrate=1_7f4dc3aa80"), "H1"); +const _H = _lazyRouteComponent(() => import("/hydrateWhenObjectFallback.tsx?tss-hydrate=0_7f4dc3aa80"), "H0"); +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate } from '@tanstack/react-start'; +import { idle, visible } from '@tanstack/react-start/hydration'; +const spreadProps = { + when: visible(), + fallback:
Bound
+}; +export function Page() { + return <> + Direct} h="0_7f4dc3aa80"> + {<_H />} + + Inline + }} h="1_7f4dc3aa80"> + {<_H2 />} + + + {<_H3 />} + + ; +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx new file mode 100644 index 0000000000..9961cdc3c9 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenPrefetchState.tsx @@ -0,0 +1,19 @@ +const _H = _lazyRouteComponent(() => import("/hydrateWhenPrefetchState.tsx?tss-hydrate=0_24b5f958eb"), "H0"), + _H0_preload = _H.preload; +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { useState } from 'react'; +import { Hydrate } from '@tanstack/react-start'; +import { interaction } from '@tanstack/react-start/hydration'; +export function Page() { + const [status, setStatus] = useState('idle'); + const [, setUnusedStatus] = useState('idle'); + return
+

{status}

+ { + setStatus('prefetched'); + setUnusedStatus('prefetched'); + }} h="0_24b5f958eb" p={_H0_preload}> + {<_H />} + +
; +} \ 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 0000000000..9c24d8cbd3 --- /dev/null +++ b/packages/start-plugin-core/tests/hydrateWhen/snapshots/client/hydrateWhenRenamed.tsx @@ -0,0 +1,11 @@ +const _H = _lazyRouteComponent(() => import("/hydrateWhenRenamed.tsx?tss-hydrate=0_f555ef3ac2"), "H0"); +import { lazyRouteComponent as _lazyRouteComponent } from "@tanstack/react-router"; +import { Hydrate as HW } from '@tanstack/react-start'; +import { interaction } from '@tanstack/react-start/hydration'; +export function Page() { + return + {<_H />} + ; +} \ 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 0000000000..b35c36ab1b --- /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 0000000000..b35c36ab1b --- /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 0000000000..b35c36ab1b --- /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 0000000000..8bcf4099ca --- /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 0000000000..6959b25b3e --- /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 <> + + + + + + + +