diff --git a/README.md b/README.md index 2c6eac8..643ddc7 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,373 @@ -# tanstack-react +# redact -A minimal, API-compatible React drop-in replacement targeting TanStack Start apps. +**React, redacted.** A minimal React-19-API-compatible drop-in replacement, **~4× smaller** than canonical React. Shipped as a single `@tanstack/redact` package with subpath exports for the `react` / `react-dom` / `react-dom/server` / `scheduler` / `react/jsx-runtime` shapes. User code keeps its canonical `import { useState } from 'react'` — the swap happens at the bundler level. -**Status:** `0.1.0-alpha.6` published to npm under `next`, running in production on [tanstack.com](https://tanstack.com) as of 2026-04-20. 677/677 unit + integration tests passing. End-to-end SSR demo streams Suspense boundaries and reveals them via the inline `$RC` runtime. +- **9.07 KB** gzip at full drop-in parity (vs ~45 KB for React 19) +- **6.75 KB** gzip with every opt-in feature stubbed (`nano` preset) +- **707/707** unit + integration tests passing, SSR + streaming Suspense + hydration included +- Running in production on [tanstack.com](https://tanstack.com) as of 2026-04-20 + +--- + +## Quick start ```bash -pnpm install -pnpm test # unit + integration tests -pnpm size # bundle-size report -pnpm --filter ssr-demo dev # serve http://localhost:5173 +pnpm add @tanstack/redact@next ``` -## Current size +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { redact } from '@tanstack/redact/vite' -| Entry | gzip | brotli | -| ------------------- | -------: | --------: | -| `react` | 2.22 KB | 2.02 KB | -| `react/jsx-runtime` | 189 B | 180 B | -| `react-dom/client` | 8.65 KB | 7.82 KB | -| **Client total** | **10.40 KB** | **9.40 KB** | -| `react-dom/server` | 4.54 KB | 4.12 KB (server-only) | +export default defineConfig({ + plugins: [redact()], +}) +``` -React 19's full client bundle is ≈ 45 KB gzip — this is roughly **4× smaller**, at the cost of concurrent-mode/scheduler depth under stress (see [Scope](#scope)). +That's it. The plugin aliases `react` / `react-dom` / `scheduler` across Vite's client + ssr environments. The RSC environment is skipped so `@vitejs/plugin-rsc` keeps using real React for Flight serialization. User-facing imports are unchanged: -## Performance +```ts +import { useState, Suspense } from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +``` -Measured against TanStack Router + TanStack Start benchmarks (`pnpm nx run @benchmarks/client-nav:test:perf:react`, `@benchmarks/ssr:test:perf:react`): +### Shrink further with feature flags -| Bench | Real React | Shim | Ratio | -| --- | -: | -: | -: | -| `client-nav` (router-driven navigation loop) | 34.9 hz | **78.1 hz** | **2.24× faster** | -| `ssr` (request loop) | ~48 hz | **168 hz** | **~3× faster**[^1] | +Two presets — pick a starting point, flip flags from there: + +```ts +redact({ preset: 'full' }) // 9.07 KB — everything on, opt OUT individual features +redact({ preset: 'nano' }) // 6.75 KB — everything off, opt IN what you need +``` + +Opt out from `full`: + +```ts +redact({ + preset: 'full', + features: { + hydration: false, // SPA only — no SSR + classComponents: false, // function components only + }, +}) +``` + +Opt in from `nano`: + +```ts +redact({ + preset: 'nano', + features: { + context: true, // bring back just what you need + suspense: true, + }, +}) +``` + +Full feature matrix and alternative configuration paths below. + +--- + +## How it works in 30 seconds + +`@tanstack/redact/dom` is built as an irreducible core (fiber reconciler, host DOM, core hooks, elements) plus **8 opt-in features** layered on top. Each feature has a `full.ts` (real implementation) and a `stub.ts` (graceful degradation). Features self-register with the reconciler at module load — renderers, type matchers, capability hooks. + +Feature selection is a bundler-level concern. The `@tanstack/redact/vite` plugin's `resolveId` hook swaps `features//index.js` → `features//stub.js` for features you've flagged off. Stubbed features' full code never enters the module graph, so tree-shaking strips it. No user-code changes. No runtime branching. + +--- + +## Feature flags + +### Feature matrix + +| Flag | Full behavior | Stub behavior (when `false`) | Savings (gzip) | +|---|---|---|---:| +| `portal` | `createPortal` into alt container | Children render in place, `container` ignored | ~30 B | +| `context` | Provider push/pop + consumer walk | Provider → Fragment; `useContext` returns default | ~80 B | +| `suspense` | Boundary + fallback + streaming hydration | Suspense → Fragment; thenables retry on settle | **~640 B** | +| `memo` | `shallowEqual` prop-equality gate | Passes through every parent render | ~80 B | +| `forwardRef` | Ref forwarded to inner fn | Ref dropped (React 19 "refs as props" still works) | ~70 B | +| `lazy` | Full hydration coordination | Sync-resolvable payloads work; async retries on settle | ~20 B | +| `classComponents` | Full lifecycle + `contextType` + error boundaries | `constructor` + `render` + `setState` only | ~200 B | +| `hydration` | SSR DOM adoption, streaming boundaries, scroll guard, event replay | `hydrateRoot` throws; use `createRoot` for SPA | **~1270 B** | + +**Always on** (irreducible core, ~6.7 KB gzip): fiber reconciler with keyed child diffing, host DOM mount/update, `useState` / `useReducer` / `useEffect` / `useLayoutEffect` / `useInsertionEffect` / `useRef` / `useMemo` / `useCallback` / `useId` / `useSyncExternalStore` / `use` (for thenables), native event binding, Fragments, StrictMode/Profiler (aliased to Fragment), element creation + JSX runtime. + +### Presets + +| Preset | What's on | `react-dom/client` gzip | Intent | +|---|---|---:|---| +| `full` (default) | all 8 features | **9.07 KB** | Drop-in React — opt OUT individual features you don't need | +| **`nano`** | none | **6.75 KB** | Start minimal — opt IN individual features you need | + +Two presets, not a spectrum: every app either wants most of React (start from `full`, opt out) or a tight bundle (start from `nano`, opt in). Per-feature overrides merge on top of preset defaults either way. + +--- + +## Configuration + +Four ways to configure, depending on your bundler and ergonomics preference. + +### 1. Vite plugin (recommended) + +`@tanstack/redact/vite`'s `redact()` plugin. Covered in [Quick start](#quick-start) above. Full options: + +```ts +interface RedactOptions { + preset?: 'nano' | 'full' // default: 'full' + features?: { + portal?: boolean + context?: boolean + suspense?: boolean + memo?: boolean + forwardRef?: boolean + lazy?: boolean + classComponents?: boolean + hydration?: boolean + } + skip?: ReadonlyArray // don't alias these specifiers + resolveFrom?: string // override package resolution root + packageRoots?: Record // explicit package paths +} +``` + +The plugin also handles Vite-specific wiring: `optimizeDeps.exclude` for the shim packages, `ssr.noExternal` so SSR bundles inline them, and an `enforce: 'pre'` hook ordering so the alias wins over other resolvers. + +### 2. Bundler aliases (Webpack / Rollup / esbuild / …) + +The package exposes every feature module as a `./features/*` subpath export. Any bundler with a path-alias feature can redirect a feature's `index` to its `stub` to opt the feature out of the bundle. + +**Subpath layout:** + +``` +@tanstack/redact/features/ + portal/ context/ suspense/ memo/ forward-ref/ lazy/ class/ hydration/ + index ← re-exports from ./full by default + full ← real implementation + stub ← graceful degradation +``` + +**Webpack example (stubs hydration + suspense):** + +```js +// webpack.config.js +module.exports = { + resolve: { + alias: { + '@tanstack/redact/features/hydration/index': + '@tanstack/redact/features/hydration/stub', + '@tanstack/redact/features/suspense/index': + '@tanstack/redact/features/suspense/stub', + }, + }, +} +``` + +**Rollup:** + +```js +import alias from '@rollup/plugin-alias' + +export default { + plugins: [ + alias({ + entries: [ + { + find: '@tanstack/redact/features/hydration/index', + replacement: '@tanstack/redact/features/hydration/stub', + }, + ], + }), + ], +} +``` + +**esbuild:** + +```js +import { build } from 'esbuild' + +await build({ + entryPoints: ['src/app.tsx'], + bundle: true, + alias: { + '@tanstack/redact/features/hydration/index': + '@tanstack/redact/features/hydration/stub', + }, +}) +``` + +**Gotchas:** + +- **On-disk folder names vs. config keys**: `forward-ref/` ↔ `forwardRef`, `class/` ↔ `classComponents`. When configuring aliases manually, match the on-disk folder. +- **Single-instance requirement**: `@tanstack/redact` (and any subpath of it) must resolve to **one** installed copy in your app. Mixing source + dist, or two different tarballs, duplicates `ReactSharedInternals` and breaks hooks. The package's `ReactSharedInternals` is stashed on `globalThis` under a registered symbol as a defense-in-depth, but you should still aim for a single copy. +- **Feature interdependencies**: Suspense's full implementation imports hydration helpers. If hydration is stubbed but Suspense is full, the Suspense feature uses hydration's no-op stubs (fine — you're not hydrating). Suspense stubbed + hydration full is also fine (streaming boundaries just won't render fallback UI because `Suspense` maps to Fragment). + +### 3. Prebuilt bundle presets (planned) + +Not yet shipped. The planned shape: + +```ts +import { createRoot } from '@tanstack/redact/dom/nano/client' +``` + +Zero bundler configuration; useful for script-tag usage, non-bundler Node tools, or users who just want the smallest install without thinking about it. -[^1]: SSR speedup requires a latent `stringifyValue` bug in `@tanstack/router-core` to be patched (exception-throwing in a hot loop was eating 34% of request time regardless of renderer — see the shim's `scripts/repro-router-hang.mjs` for a reproducer). +**Why not yet:** the preset bundle would need its own self-contained `_all.js` built with the right stubs compiled in — stubs can't reliably overlay a module that registers full variants first (registration order matters, last-write-wins). We want to gather real Vite-plugin usage data before deciding which prebuilt configurations are worth publishing. Open an issue with your use case if this unblocks you. -On tanstack.com itself (full site, including router + store + app code, not just the React portion): Lighthouse perf scores at parity with stock React, consistent FCP wins across desktop/mobile, mild LCP regression on RSC-heavy pages (tied to the shim's Flight-deserialize suspend/resume), CLS/TBT ≈ 0. Full 30-run median breakdown: [`tanstack.com/docs/perf/lighthouse-shim-vs-react-2026-04-20.md`](https://github.com/TanStack/tanstack.com/blob/main/docs/perf/lighthouse-shim-vs-react-2026-04-20.md). +### 4. npm aliases (limited) -## Goal +`npm:` package aliases in `package.json` work for the top-level `react` mapping but **not** for subpaths — there's no spec-level way to point `react-dom` at a subpath like `@tanstack/redact/dom` purely via `package.json`. So this path only gets you partway: -Satisfy `react` / `react-dom` / `react-dom/server` / `react/jsx-runtime` / `scheduler` imports used by TanStack Router + Start, at a fraction of the bundle size. ~10 KB gzipped client is the current reality. +```jsonc +// package.json — works, but only swaps `react` itself +{ + "dependencies": { + "react": "npm:@tanstack/redact@next" + } +} +``` + +Anything that imports `react-dom`, `react-dom/client`, `react-dom/server`, or `scheduler` will still resolve to the real React in `node_modules` unless your bundler can rewrite those specifiers — at which point you may as well use Path 1 (Vite plugin) or Path 2 (bundler aliases). This is a real trade-off of the single-package layout: the install side is simpler but the no-bundler workflow loses some flexibility versus a multi-package shim. If you need a no-bundler full swap, open an issue with your toolchain and we can publish individual `@tanstack/redact-dom`, `@tanstack/redact-server`, etc. compatibility re-export packages. + +--- + +## Advanced: authoring custom features & bundler plugins + +If you're extending the system, writing a bundler plugin for a tool without one, or just curious how the swap works — the internal API surface is exported from `@tanstack/redact/_all`. + +### Registration primitives + +Feature modules self-register by calling these at module load: + +```ts +import { + registerRenderer, + registerTypeMatcher, + registerElementMarker, + type RenderFn, + type TypeMatcher, +} from '@tanstack/redact/_all' + +// Install a renderer for a FiberTag. Later calls overwrite earlier ones — +// stubs exploit this order-dependence. +function registerRenderer(tag: FiberTag, fn: RenderFn): void + +// Add a type matcher. Iterated in registration order during fiber creation, +// after core checks (string → Host, REACT_FRAGMENT_TYPE → Fragment) and +// before the function-vs-class fallback. +type TypeMatcher = (type: any, marker: any) => FiberTag | null +function registerTypeMatcher(m: TypeMatcher): void + +// Extend the accepted $$typeof set for child normalization. Default: +// REACT_ELEMENT_TYPE, REACT_LEGACY_ELEMENT_TYPE. Portal adds REACT_PORTAL_TYPE. +function registerElementMarker(sym: symbol): void +``` + +### Capability hooks + +Cross-cutting concerns (thrown-thenable handling, context reads) install via `installCapability`: + +```ts +import { installCapability, type Capabilities } from '@tanstack/redact/_all' + +interface Capabilities { + handleSuspended: (fiber: Fiber, thenable: Promise) => void + readContext: (fiber: Fiber, ctx: any) => any +} + +function installCapability( + name: K, + fn: Capabilities[K], +): void +``` + +Defaults when no feature installs an override: +- `handleSuspended`: retry-on-settle (no boundary stack, no fallback) +- `readContext`: returns `ctx._currentValue` with no provider-tree walk + +The full Suspense feature installs a boundary-stack-based `handleSuspended`. The full Context feature installs a walking `readContext`. + +### Authoring a custom feature + +```ts +// my-feature/full.ts +import { + FiberTag, + registerRenderer, + registerTypeMatcher, + reconcileChildren, + childrenToArray, + type Fiber, +} from '@tanstack/redact/_all' +import { SOME_SYMBOL } from '@tanstack/redact' + +function renderMyThing(fiber: Fiber, domParent: Node, anchor: Node | null): void { + // your render logic +} + +registerTypeMatcher((_type, marker) => + marker === SOME_SYMBOL ? FiberTag.SomeTag : null, +) +registerRenderer(FiberTag.SomeTag, renderMyThing) +``` + +```ts +// my-feature/stub.ts +import { FiberTag, registerTypeMatcher } from '@tanstack/redact/_all' +import { SOME_SYMBOL } from '@tanstack/redact' + +// Stub: treat my-thing elements as Fragments (children render normally). +registerTypeMatcher((_type, marker) => + marker === SOME_SYMBOL ? FiberTag.Fragment : null, +) +``` + +Pair with an `index.ts` (`export * from './full'`) and let your bundler pick which to import. + +### Authoring a bundler plugin + +The Vite plugin's core is two `resolveId` cases. Port this pattern to any bundler's resolve hook: + +```ts +// Case 1: short specifier from features/index.ts +// Matches `./portal`, `./context`, etc. +if (importer matches /features[/\\]index\.(ts|js)$/) { + const name = id.match(/^\.\/([a-z-]+)$/)?.[1] + if (name && flags[name] === false) { + return resolveFrom(`./${name}/stub`, importer) + } +} + +// Case 2: resolved-path match for hydration +// (imported from reconcile, root, suspense/full, lazy/full) +if (flags.hydration === false && /\/hydration$/.test(id)) { + const resolved = await resolve(id, importer) + if (/features[/\\]hydration[/\\]index\.(ts|js)$/.test(resolved)) { + return resolved.replace(/index\.(ts|js)$/, 'stub.$1') + } +} +``` + +Real implementation: [packages/redact/src/vite/index.ts](packages/redact/src/vite/index.ts). + +### Verifying your setup + +Whichever path you choose, check that stubbed features' full code isn't in your output. Use your bundler's analyzer (rollup-plugin-visualizer, Webpack's bundle-analyzer, etc.) and search for `features//full.js`. With `hydration: false`, you should NOT see `features/hydration/full.js` or its imports (cursor machinery, event-replay, scroll-guard). + +--- ## Scope ### Supported - React 19 element model, JSX (classic + automatic), Fragment, Suspense, Portal, Error boundaries, forwardRef, memo, lazy -- Full hook surface: `useState`, `useReducer`, `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useMemo`, `useCallback`, `useRef`, `useContext`, `useSyncExternalStore`, `useId`, `useDeferredValue`, `useTransition`, `use` (Context + Promise) -- Class components (`componentDidMount`/`componentDidUpdate`/`componentWillUnmount`, `contextType`, legacy lifecycles as no-ops) +- Full hook surface: `useState`, `useReducer`, `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useMemo`, `useCallback`, `useRef`, `useContext`, `useSyncExternalStore`, `useId`, `useDeferredValue`, `useTransition`, `use` (Context + Promise), `useEffectEvent` +- Class components with full lifecycle (`componentDidMount`/`componentDidUpdate`/`componentWillUnmount`, `contextType`, `shouldComponentUpdate`, `getDerivedStateFromError`, `componentDidCatch`, legacy lifecycles as no-ops) - SSR via `renderToString` / `renderToReadableStream` / `renderToPipeableStream` — including Suspense boundary streaming with `$RC` reveal + event replay -- Hydration: adoption of SSR DOM, deferred-hydration for `use(promise)` / lazy with cursor preservation across the synchronous `endHydration` -- Cohabitation with `@vitejs/plugin-rsc`: `@tanstack/dom-vite` deliberately skips the RSC Vite environment so Flight serialization stays on real `react-server-dom` +- Hydration: SSR DOM adoption, deferred hydration for `use(promise)` / lazy, cursor preservation across the synchronous `endHydration` +- Cohabitation with `@vitejs/plugin-rsc`: the Vite plugin deliberately skips the RSC environment so Flight serialization stays on real `react-server-dom` ### Best-effort / subset behavior @@ -63,69 +381,101 @@ Satisfy `react` / `react-dom` / `react-dom/server` / `react/jsx-runtime` / `sche - React DevTools protocol - Behavioral 1:1 parity with React under concurrent-mode stress -See [`docs/SURFACE.md`](./docs/SURFACE.md) for the full React-19 export-by-export audit and implementation plan. +See [docs/SURFACE.md](./docs/SURFACE.md) for the full React-19 export-by-export audit. + +--- + +## Performance + +Measured against TanStack Router + TanStack Start benchmarks (`pnpm nx run @benchmarks/client-nav:test:perf:react`, `@benchmarks/ssr:test:perf:react`): + +| Bench | Real React | This shim | Ratio | +|---|---:|---:|---:| +| `client-nav` (router-driven navigation loop) | 34.9 hz | **78.1 hz** | **2.24× faster** | +| `ssr` (request loop) | ~48 hz | **168 hz** | **~3× faster**[^1] | + +[^1]: SSR speedup requires a latent `stringifyValue` bug in `@tanstack/router-core` to be patched (exception-throwing in a hot loop was eating 34% of request time regardless of renderer — see `scripts/repro-router-hang.mjs`). + +On tanstack.com (full site, not just renderer): Lighthouse perf scores at parity with stock React, consistent FCP wins across desktop/mobile, mild LCP regression on RSC-heavy pages (tied to the shim's Flight-deserialize suspend/resume), CLS/TBT ≈ 0. Full 30-run median breakdown: [tanstack.com/docs/perf/lighthouse-shim-vs-react-2026-04-20.md](https://github.com/TanStack/tanstack.com/blob/main/docs/perf/lighthouse-shim-vs-react-2026-04-20.md). + +--- + +## Development + +### Layout -## Layout +One package, one tree, internal subdirectories per concern: ``` -packages/ - core/ VDOM types + diff - react/ 'react' entrypoint - react-dom/ 'react-dom' + 'react-dom/client' - react-dom-server/ 'react-dom/server' (renderToString/Stream) - jsx-runtime/ 'react/jsx-runtime' + 'react/jsx-dev-runtime' - scheduler/ 'scheduler' shim - dom-vite/ Vite plugin: aliases react/react-dom/scheduler/etc. → shim - hydration-runtime/ inline client runtime (boundary reveal, event replay) -tests/ vitest suite with aliases pointing at workspace packages +packages/redact/src/ + core/ VDOM types + symbols (FiberTag, Hook, ReactNode, …) + react/ 'react' entry: createElement, hooks, context, class, + memo, suspense, jsx-runtime, ReactSharedInternals + dom/ 'react-dom' entry: reconciler, host DOM, root, + createPortal, flushSync + features/ opt-in features (each is an index/full/stub triple) + portal/ context/ suspense/ memo/ + forward-ref/ lazy/ class/ hydration/ + server/ 'react-dom/server' entry: renderToString, + renderToReadableStream, renderToPipeableStream + scheduler/ 'scheduler' shim (no-op microtask wrapper) + vite/ redact() Vite plugin: aliases + feature-flag swaps +tests/ vitest suite — 707 tests examples/ - ssr-demo/ full SSR + Suspense streaming smoke app - dom-jsx-playground/ legacy direct-to-DOM JSX experiment (reference only) + ssr-demo/ full SSR + Suspense streaming smoke app docs/ - SURFACE.md React 19 export audit and implementation plan - SAVINGS_ANALYSIS.md per-export size savings vs React 19 + SURFACE.md React 19 export audit + SAVINGS_ANALYSIS.md per-export size savings vs React 19 scripts/ - repro-router-hang.mjs reproducer for the router's Date.now() loadedAt bug - prof-ssr.mjs drives N SSR requests for CPU-profile capture - analyze-cpuprofile.mjs top-N self-time frames from a .cpuprofile - bucket-cpuprofile.mjs categorizes .cpuprofile (shim / router / node / native) + build.mjs per-entry esbuild build (every TS module emitted) + size.mjs per-preset / per-flag gzip report + size-check.mjs CI size-budget assertions + size-analyze.mjs per-module byte breakdown for a given preset ``` -## Consuming +Cross-subdir imports inside `packages/redact/src/` use relative paths +(`../core`, `../react`, etc.). The build emits each TS module as its own +dist file with all relative imports kept literal — that's what preserves the +import-graph boundaries the Vite plugin needs to swap features at consumer +build time. -Two patterns for swapping React in a consumer app: - -**Via Vite plugin (recommended):** +### Commands ```bash -npm install -D @tanstack/dom-vite@next +pnpm install +pnpm build # esbuild dist/ + tsc declaration emit +pnpm test # vitest suite (707 tests) +pnpm test:types # tsc --noEmit +pnpm size # gzip/brotli per entry + per feature-stub +pnpm size:check # CI budget assertions (fails on regression) +pnpm --filter ssr-demo dev # serve http://localhost:5173 ``` -```ts -// vite.config.ts -import { tanstackDom } from '@tanstack/dom-vite' +### Current sizes -export default defineConfig({ - plugins: [tanstackDom(), /* ... */], -}) -``` +Subpath sizes from `pnpm size`. The `react` / `react-dom/client` / `react-dom/server` column names are the user-facing aliases the Vite plugin sets up; under the hood they all resolve into `@tanstack/redact/*`. -The plugin aliases `react`, `react-dom`, `react-dom/client`, `react-dom/server`, `react/jsx-runtime`, and `scheduler` across the client + ssr Vite environments. It deliberately skips the `rsc` environment so `@vitejs/plugin-rsc` keeps using real React for Flight serialization. Runtime-specific server variants (`react-dom/server.edge`, `.node`, `.bun`, `.browser`, `react-dom/static.*`) aren't in the default map — alias those manually at top-level `resolve.alias` in consuming apps if needed (see [tanstack.com's vite.config.ts](https://github.com/TanStack/tanstack.com/blob/main/vite.config.ts) for a full example). +| Entry | min | gzip | brotli | +|---|---:|---:|---:| +| `react` (= `@tanstack/redact`) | 6.59 KB | 2.65 KB | 2.41 KB | +| `react/jsx-runtime` (= `@tanstack/redact/jsx-runtime`) | 247 B | 189 B | 178 B | +| `react-dom/client` (= `@tanstack/redact/dom-client`, `full`) | 26.56 KB | **9.07 KB** | 8.21 KB | +| `react-dom/client` (= `@tanstack/redact/dom-client`, `nano`) | 18.75 KB | **6.75 KB** | 6.10 KB | +| `react-dom/server` (= `@tanstack/redact/server`) | 11.48 KB | 4.59 KB | 4.16 KB | +| **Client total** (`full`: react + react-dom/client + jsx-runtime) | 32.63 KB | **11.18 KB** | 10.14 KB | -**Via npm aliases (no bundler plugin):** +Regenerate with `pnpm size`. -```jsonc -{ - "dependencies": { - "react": "npm:@tanstack/react@next", - "react-dom": "npm:@tanstack/react-dom@next", - "scheduler": "npm:@tanstack/scheduler@next" - } -} -``` +--- + +## Changelog -## Recent fixes +The project's first 9 alpha versions shipped as separate `@tanstack/react`, `@tanstack/react-dom`, `@tanstack/react-dom-server`, `@tanstack/dom-core`, `@tanstack/scheduler`, and `@tanstack/dom-vite` packages (`0.1.0-alpha.0` … `0.1.0-alpha.9`). Those packages are now deprecated. The project starts fresh as a single `@tanstack/redact` (`0.0.1`+) with subpath exports — the fixes below predate the rename and the package names refer to the previous multi-package layout. -- `react-dom@0.1.0-alpha.5` — `useEffect` / `useLayoutEffect` cleanup now runs at effect-run time (in the passive drain) instead of dispatch time. Coalesced renders that land back-to-back before the drain (common with router/store state updates triggered by a single user action) no longer leak side-effects into the DOM. +- `@tanstack/redact@0.0.1` — **first release of `@tanstack/redact`**. Consolidates the 6 previously-separate alpha packages into a single package with subpath exports (`./jsx-runtime`, `./dom`, `./dom-client`, `./dom-test-utils`, `./server`, `./scheduler`, `./vite`, `./features/*`, `./_all`). Vite plugin renamed `tanstackDom()` → `redact()`, types `TanStackDom*` → `Redact*`. `ReactSharedInternals` made a `globalThis`-stashed singleton via `Symbol.for` to defend against duplicate package copies under bundlers like Cloudflare's `vite-plugin` that mix `noExternal: true` worker bundling with separate pre-bundled dep copies. New `tests/public-exports.test.ts` snapshot guards every subpath's named-export set against silent link-time drift. +- `react@0.1.0-alpha.8` — added `useEffectEvent` hook (stable callback over a `useInsertionEffect`-refreshed ref). Fixes missing-export errors in consumers using React 19 event handlers. +- `react-dom@0.1.0-alpha.8` — **feature-flag system landed**: 8 opt-in features with stub/full pairs, typed Vite plugin config, `pnpm size:check` CI budget enforcement. `nano` preset ships **6.75 KB gzip** — a 26% reduction from `full`. +- `react-dom@0.1.0-alpha.5` — `useEffect` / `useLayoutEffect` cleanup now runs at effect-run time (in the passive drain) instead of dispatch time. Coalesced renders landing back-to-back before the drain (common with router/store state updates triggered by one user action) no longer leak side-effects into the DOM. - `react-dom@0.1.0-alpha.4` — `renderFunction`'s deferred-hydration branch now matches `renderLazy`'s ancestor-Suspense guard (`_awaitingLazyHydration`). Fixes duplicate markup on RSC-hydrated subtrees. - `react-dom-server@0.1.0-alpha.4` — shell + bootstrap emits are buffered into one `TextEncoder.encode` + `ReadableStream.enqueue` instead of per-chunk, cutting Node stream overhead in the SSR CPU profile. + diff --git a/examples/ssr-demo/package.json b/examples/ssr-demo/package.json index 10d4c4e..281ee6a 100644 --- a/examples/ssr-demo/package.json +++ b/examples/ssr-demo/package.json @@ -11,10 +11,7 @@ "preview": "NODE_ENV=production tsx server.ts" }, "dependencies": { - "@tanstack/dom-vite": "workspace:*", - "@tanstack/react": "workspace:*", - "@tanstack/react-dom": "workspace:*", - "@tanstack/react-dom-server": "workspace:*" + "@tanstack/redact": "workspace:*" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/examples/ssr-demo/tsconfig.json b/examples/ssr-demo/tsconfig.json index 4f83cf4..81f0673 100644 --- a/examples/ssr-demo/tsconfig.json +++ b/examples/ssr-demo/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "types": ["node"], "jsx": "react-jsx", - "jsxImportSource": "@tanstack/react" + "jsxImportSource": "@tanstack/redact" }, "include": ["src/**/*", "server.ts", "vite.config.ts"] } diff --git a/examples/ssr-demo/vite.config.ts b/examples/ssr-demo/vite.config.ts index f03912a..9a67fd0 100644 --- a/examples/ssr-demo/vite.config.ts +++ b/examples/ssr-demo/vite.config.ts @@ -1,11 +1,11 @@ import { defineConfig } from 'vite' -import { tanstackDom } from '@tanstack/dom-vite' +import { redact } from '@tanstack/redact/vite' export default defineConfig({ - plugins: [tanstackDom()], + plugins: [redact()], esbuild: { jsx: 'automatic', - jsxImportSource: '@tanstack/react', + jsxImportSource: '@tanstack/redact', }, server: { middlewareMode: true }, appType: 'custom', diff --git a/package.json b/package.json index 07fa95c..a5412af 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "pnpm --filter tests test", "test:types": "tsc --noEmit", "size": "node scripts/size.mjs", + "size:check": "node scripts/size-check.mjs", "example:playground": "pnpm --filter dom-jsx-playground dev" }, "devDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json deleted file mode 100644 index dd88a2b..0000000 --- a/packages/core/package.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "@tanstack/dom-core", - "version": "0.1.0-alpha.8", - "description": "VDOM types and diff algorithm for tanstack-react", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "scripts": { - "build": "echo done-by-root-build" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts deleted file mode 100644 index bc25dfd..0000000 --- a/packages/core/src/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types'; -export * from './internal'; diff --git a/packages/core/src/internal.d.ts b/packages/core/src/internal.d.ts deleted file mode 100644 index 2fe2d71..0000000 --- a/packages/core/src/internal.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { ReactElement, ReactNode, Ref } from './types'; -export declare const enum FiberTag { - Host = 0, - Text = 1, - Function = 2, - Class = 3, - Fragment = 4, - Portal = 5, - Provider = 6, - Consumer = 7, - ForwardRef = 8, - Memo = 9, - Lazy = 10, - Suspense = 11, - Root = 12 -} -export declare const enum FiberFlag { - None = 0, - Placement = 1, - Update = 2, - Deletion = 4, - Ref = 8, - Effect = 16, - LayoutEffect = 32, - ContentReset = 64, - DidCapture = 128 -} -export interface Hook { - state: any; - queue: any; - deps: any; - cleanup: any; - next: Hook | null; -} -export interface Effect { - tag: 'effect' | 'layout' | 'insertion'; - create: () => any; - destroy: (() => void) | void; - deps: ReadonlyArray | undefined; -} -export interface Fiber { - tag: FiberTag; - type: any; - key: string | null; - ref: Ref | null; - pendingProps: any; - memoizedProps: any; - memoizedState: any; - stateNode: any; - dom: Node | null; - parent: Fiber | null; - child: Fiber | null; - sibling: Fiber | null; - hooks: Hook | null; - effects: Effect[] | null; - layoutEffects: Effect[] | null; - cleanups: Array<() => void> | null; - flags: FiberFlag; - dirty: boolean; - unmounted: boolean; - root: FiberRoot | null; -} -export interface FiberRoot { - container: Element | DocumentFragment; - current: Fiber; - pending: Set; - scheduled: boolean; - onRecoverableError?: (err: unknown) => void; - onCaughtError?: (err: unknown) => void; - onUncaughtError?: (err: unknown) => void; - identifierPrefix: string; - hydrating: boolean; -} -export declare function createFiber(tag: FiberTag, type: any, key: string | null): Fiber; -export type ChildNode = ReactElement | string | number | boolean | null | undefined | ChildNode[]; -export type { ReactElement, ReactNode }; diff --git a/packages/core/src/types.d.ts b/packages/core/src/types.d.ts deleted file mode 100644 index 679269e..0000000 --- a/packages/core/src/types.d.ts +++ /dev/null @@ -1,53 +0,0 @@ -export declare const REACT_ELEMENT_TYPE: unique symbol; -export declare const REACT_LEGACY_ELEMENT_TYPE: unique symbol; -export declare const REACT_FRAGMENT_TYPE: unique symbol; -export declare const REACT_PORTAL_TYPE: unique symbol; -export declare const REACT_PROVIDER_TYPE: unique symbol; -export declare const REACT_CONTEXT_TYPE: unique symbol; -export declare const REACT_CONSUMER_TYPE: unique symbol; -export declare const REACT_FORWARD_REF_TYPE: unique symbol; -export declare const REACT_SUSPENSE_TYPE: unique symbol; -export declare const REACT_MEMO_TYPE: unique symbol; -export declare const REACT_LAZY_TYPE: unique symbol; -export declare const REACT_STRICT_MODE_TYPE: unique symbol; -export declare const REACT_PROFILER_TYPE: unique symbol; -export type Key = string | number | null | undefined; -export interface ReactElement

{ - $$typeof: typeof REACT_ELEMENT_TYPE; - type: T; - key: string | null; - ref: any; - props: P; -} -export type ReactNode = ReactElement | string | number | boolean | null | undefined | Iterable; -export type RefObject = { - current: T | null; -}; -export type RefCallback = (instance: T | null) => void | (() => void); -export type Ref = RefObject | RefCallback | null; -export type Dispatch = (value: A) => void; -export type SetStateAction = S | ((prev: S) => S); -export type EffectCallback = () => void | (() => void); -export type DependencyList = ReadonlyArray; -export interface Context { - $$typeof: typeof REACT_CONTEXT_TYPE; - _currentValue: T; - Provider: ProviderExoticComponent<{ - value: T; - children?: ReactNode; - }>; - Consumer: ConsumerExoticComponent; - displayName?: string; -} -export interface ProviderExoticComponent

{ - $$typeof: typeof REACT_PROVIDER_TYPE; - _context: Context; - (props: P): ReactElement; -} -export interface ConsumerExoticComponent { - $$typeof: typeof REACT_CONSUMER_TYPE; - _context: Context; - (props: { - children: (value: T) => ReactNode; - }): ReactElement; -} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts deleted file mode 100644 index 42f928b..0000000 --- a/packages/core/src/types.ts +++ /dev/null @@ -1,65 +0,0 @@ -// React 19+ uses the transitional element symbol. When our shim's elements -// are passed to react-dom (e.g. Start routing through real react-dom/server -// somewhere), they must carry the symbol that React's isValidElement checks. -export const REACT_ELEMENT_TYPE = Symbol.for('react.transitional.element') -export const REACT_LEGACY_ELEMENT_TYPE = Symbol.for('react.element') -export const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment') -export const REACT_PORTAL_TYPE = Symbol.for('react.portal') -export const REACT_PROVIDER_TYPE = Symbol.for('react.provider') -export const REACT_CONTEXT_TYPE = Symbol.for('react.context') -export const REACT_CONSUMER_TYPE = Symbol.for('react.consumer') -export const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref') -export const REACT_SUSPENSE_TYPE = Symbol.for('react.suspense') -export const REACT_MEMO_TYPE = Symbol.for('react.memo') -export const REACT_LAZY_TYPE = Symbol.for('react.lazy') -export const REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode') -export const REACT_PROFILER_TYPE = Symbol.for('react.profiler') - -export type Key = string | number | null | undefined - -export interface ReactElement

{ - $$typeof: typeof REACT_ELEMENT_TYPE - type: T - key: string | null - ref: any - props: P -} - -export type ReactNode = - | ReactElement - | string - | number - | boolean - | null - | undefined - | Iterable - -export type RefObject = { current: T | null } -export type RefCallback = (instance: T | null) => void | (() => void) -export type Ref = RefObject | RefCallback | null - -export type Dispatch = (value: A) => void -export type SetStateAction = S | ((prev: S) => S) - -export type EffectCallback = () => void | (() => void) -export type DependencyList = ReadonlyArray - -export interface Context { - $$typeof: typeof REACT_CONTEXT_TYPE - _currentValue: T - Provider: ProviderExoticComponent<{ value: T; children?: ReactNode }> - Consumer: ConsumerExoticComponent - displayName?: string -} - -export interface ProviderExoticComponent

{ - $$typeof: typeof REACT_PROVIDER_TYPE - _context: Context - (props: P): ReactElement -} - -export interface ConsumerExoticComponent { - $$typeof: typeof REACT_CONSUMER_TYPE - _context: Context - (props: { children: (value: T) => ReactNode }): ReactElement -} diff --git a/packages/dom-vite/package.json b/packages/dom-vite/package.json deleted file mode 100644 index e734ea2..0000000 --- a/packages/dom-vite/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@tanstack/dom-vite", - "version": "0.1.0-alpha.8", - "description": "Vite plugin — installs tanstack-react in place of react/react-dom.", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "dependencies": { - "@tanstack/dom-core": "0.1.0-alpha.8", - "@tanstack/scheduler": "0.1.0-alpha.8", - "@tanstack/react": "0.1.0-alpha.8", - "@tanstack/react-dom": "0.1.0-alpha.8", - "@tanstack/react-dom-server": "0.1.0-alpha.8" - }, - "peerDependencies": { - "vite": ">=5" - }, - "scripts": { - "build": "echo done-by-root-build" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/packages/dom-vite/src/index.ts b/packages/dom-vite/src/index.ts deleted file mode 100644 index 592dcde..0000000 --- a/packages/dom-vite/src/index.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { dirname, resolve as resolvePath } from 'node:path' -import { fileURLToPath } from 'node:url' - -export interface TanStackDomOptions { - /** Skip aliasing specific specifiers, e.g. if a consumer wants real React somewhere. */ - skip?: ReadonlyArray - /** - * Override package resolution root. Defaults to the Vite config root. Useful - * for monorepos where the plugin lives in a different workspace than the - * consumer app. - */ - resolveFrom?: string - /** - * Explicit package roots, bypassing node_modules lookup. Keys are package - * names (e.g. `@tanstack/react`), values are absolute paths to the package - * directory. Handy for cross-workspace testing / bring-your-own-build setups. - */ - packageRoots?: Record -} - -const ALIASES: Record = { - // Shim targets - react: '@tanstack/react', - 'react/jsx-runtime': '@tanstack/react/jsx-runtime', - 'react/jsx-dev-runtime': '@tanstack/react/jsx-dev-runtime', - 'react-dom': '@tanstack/react-dom', - 'react-dom/client': '@tanstack/react-dom/client', - 'react-dom/server': '@tanstack/react-dom-server', - 'react-dom/test-utils': '@tanstack/react-dom/test-utils', - scheduler: '@tanstack/scheduler', - // Internal @tanstack/* aliases so Vite consistently picks source .ts over - // published dist/.js. Without these, mixing source + dist loads two copies - // of ReactSharedInternals and hooks break. - '@tanstack/react': '@tanstack/react', - '@tanstack/react/jsx-runtime': '@tanstack/react/jsx-runtime', - '@tanstack/react/jsx-dev-runtime': '@tanstack/react/jsx-dev-runtime', - '@tanstack/react-dom': '@tanstack/react-dom', - '@tanstack/react-dom/client': '@tanstack/react-dom/client', - '@tanstack/react-dom/test-utils': '@tanstack/react-dom/test-utils', - '@tanstack/react-dom-server': '@tanstack/react-dom-server', - '@tanstack/dom-core': '@tanstack/dom-core', - '@tanstack/scheduler': '@tanstack/scheduler', -} - -function splitSpecifier(specifier: string): { pkg: string; sub: string } { - if (specifier.startsWith('@')) { - const slash1 = specifier.indexOf('/') - const slash2 = specifier.indexOf('/', slash1 + 1) - if (slash2 < 0) return { pkg: specifier, sub: '' } - return { pkg: specifier.slice(0, slash2), sub: specifier.slice(slash2 + 1) } - } - const slash = specifier.indexOf('/') - if (slash < 0) return { pkg: specifier, sub: '' } - return { pkg: specifier.slice(0, slash), sub: specifier.slice(slash + 1) } -} - -function findPackageDir(pkg: string, fromDir: string): string | null { - let dir = fromDir - while (true) { - const candidate = resolvePath(dir, 'node_modules', pkg) - if (existsSync(resolvePath(candidate, 'package.json'))) return candidate - const parent = dirname(dir) - if (parent === dir) return null - dir = parent - } -} - -function resolveExport(packageDir: string, sub: string): string | null { - const pkgJsonPath = resolvePath(packageDir, 'package.json') - let pkg: any - try { - pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) - } catch { - return null - } - const key = sub ? './' + sub : '.' - const exp = pkg.exports?.[key] - // Prefer published `import` (dist/.js) over `source` — dist is a single - // transformed bundle per package so Vite's dep optimizer doesn't thrash on - // dozens of individual source files. Our build keeps cross-@tanstack - // imports external, so there's still only one React instance at runtime. - const pick = (v: any): string | null => { - if (typeof v === 'string') return v - if (v && typeof v === 'object') { - return pick(v.import ?? v.module ?? v.source ?? v.default ?? null) - } - return null - } - const target = pick(exp) - if (target) return resolvePath(packageDir, target) - if (!sub) { - const main = pkg.module ?? pkg.main - if (typeof main === 'string') return resolvePath(packageDir, main) - } - return null -} - -// When installed from npm, the shim packages are declared as `dependencies` -// of this plugin. Under pnpm's strict mode they end up nested under the -// plugin's own `.pnpm/@tanstack+dom-vite@.../node_modules/` rather than -// hoisted to the consumer's root, so a `findPackageDir` walk starting at the -// Vite project root won't find them. Search from the plugin's own directory -// first (which walks into its nested node_modules), then fall back to the -// consumer root for overridden/hoisted installs. -const pluginDir = dirname(fileURLToPath(import.meta.url)) - -function resolveSpecifier( - specifier: string, - fromDir: string, - packageRoots: Record, -): string | null { - const { pkg, sub } = splitSpecifier(specifier) - const packageDir = - packageRoots[pkg] ?? - findPackageDir(pkg, pluginDir) ?? - findPackageDir(pkg, fromDir) - if (!packageDir) return null - const target = resolveExport(packageDir, sub) - if (!target) return null - // Canonicalize through pnpm symlinks. Under strict pnpm, the shim packages - // live nested under the plugin's own `.pnpm/@tanstack+dom-vite@.../node_modules/*`, - // but each of those is itself a symlink to the flat `.pnpm/@tanstack+react@.../` - // entry. Vite's `fetchModule` (used by TanStack Start's server-fn compiler) - // follows the realpath, so the id seen by the capture-transform differs from - // the nested id we'd return. That leaves the compiler's moduleCache keyed on - // the realpath while `getModuleInfo` looks up the nested path → miss → - // "could not load module info". Returning the canonical realpath here keeps - // the two sides in agreement. - try { - return realpathSync(target) - } catch { - return target - } -} - -export function tanstackDom(options: TanStackDomOptions = {}): any { - const skip = new Set(options.skip ?? []) - const entries = Object.entries(ALIASES).filter(([k]) => !skip.has(k)) - - const resolvedMap: Record = {} - let done = false - - function resolveAll(root: string): void { - if (done) return - const fromDir = options.resolveFrom ?? root - const packageRoots = options.packageRoots ?? {} - for (const [from, to] of entries) { - const resolved = resolveSpecifier(to, fromDir, packageRoots) - if (resolved) resolvedMap[from] = resolved - } - done = true - } - - return { - name: 'tanstack-react', - enforce: 'pre', - - config() { - const excludeList = entries.map(([k]) => k) - const noExt = [ - '@tanstack/react', - '@tanstack/react-dom', - '@tanstack/react-dom-server', - '@tanstack/dom-core', - '@tanstack/scheduler', - ] - // Scope optimizeDeps to client + ssr environments ONLY. Do NOT set a - // top-level optimizeDeps — in Vite 6+ that's effectively the client - // env's default but also seeps into the rsc env's `'use client'` - // analysis, causing flood warnings like "inconsistently optimized". - return { - environments: { - client: { - optimizeDeps: { exclude: excludeList }, - }, - ssr: { - optimizeDeps: { exclude: excludeList }, - resolve: { noExternal: noExt }, - }, - }, - ssr: { noExternal: noExt }, - } - }, - - configResolved(config: any) { - resolveAll(config.root) - // With `packageRoots`, package sources live outside the consumer's Vite - // project root, so the default server.fs.allow list blocks them. Append - // to the resolved allow list rather than replacing via `config()`, so we - // keep Vite's defaults (root + node_modules + client runtime). - const fsAllow = Object.values(options.packageRoots ?? {}) - if (fsAllow.length && config.server?.fs?.allow) { - for (const p of fsAllow) { - if (!config.server.fs.allow.includes(p)) { - config.server.fs.allow.push(p) - } - } - } - }, - - resolveId(this: any, id: string) { - // Skip the RSC environment — it relies on real React internals via - // @vitejs/plugin-rsc's vendored react-server-dom. Substituting our shim - // there breaks Flight serialization. Client + SSR envs still swap. - const envName = this?.environment?.name - if (envName === 'rsc') return null - return resolvedMap[id] ?? null - }, - } -} - -export default tanstackDom diff --git a/packages/hydration-runtime/package.json b/packages/hydration-runtime/package.json deleted file mode 100644 index 716a415..0000000 --- a/packages/hydration-runtime/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@tanstack/dom-hydration-runtime", - "version": "0.0.0", - "description": "Inline client runtime for streaming Suspense boundary reveal and event replay", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./inline": { - "types": "./dist/inline.d.ts", - "import": "./dist/inline.js" - } - }, - "files": [ - "dist" - ], - "sideEffects": false, - "scripts": { - "build": "echo TODO", - "dev": "echo TODO", - "size": "echo TODO" - }, - "private": true -} diff --git a/packages/jsx-runtime/package.json b/packages/jsx-runtime/package.json deleted file mode 100644 index 16c1110..0000000 --- a/packages/jsx-runtime/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@tanstack/jsx-runtime", - "version": "0.0.0", - "description": "JSX runtime (classic + automatic) for tanstack-react", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist" - ], - "sideEffects": false, - "dependencies": { - "@tanstack/dom-core": "workspace:*" - }, - "scripts": { - "build": "echo TODO", - "dev": "echo TODO", - "size": "echo TODO" - }, - "private": true -} diff --git a/packages/react-dom-server/package.json b/packages/react-dom-server/package.json deleted file mode 100644 index b15434f..0000000 --- a/packages/react-dom-server/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@tanstack/react-dom-server", - "version": "0.1.0-alpha.8", - "description": "Drop-in 'react-dom/server' replacement for TanStack Start apps", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "dependencies": { - "@tanstack/dom-core": "0.1.0-alpha.8", - "@tanstack/react": "0.1.0-alpha.8" - }, - "scripts": { - "build": "echo done-by-root-build" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/packages/react-dom/package.json b/packages/react-dom/package.json deleted file mode 100644 index f913a8f..0000000 --- a/packages/react-dom/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@tanstack/react-dom", - "version": "0.1.0-alpha.8", - "description": "Drop-in 'react-dom' replacement for TanStack Start apps", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - }, - "./client": { - "types": "./src/client.ts", - "import": "./dist/client.js" - }, - "./test-utils": { - "types": "./src/test-utils.ts", - "import": "./dist/test-utils.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "dependencies": { - "@tanstack/dom-core": "0.1.0-alpha.8", - "@tanstack/react": "0.1.0-alpha.8" - }, - "scripts": { - "build": "echo done-by-root-build" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/packages/react-dom/src/_all.ts b/packages/react-dom/src/_all.ts deleted file mode 100644 index e32348a..0000000 --- a/packages/react-dom/src/_all.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Internal unified entry. Builds into a single `dist/all.js` so every -// public entry (index, client, test-utils) can re-export from the same -// module, guaranteeing ONE copy of internals like the hydration CLAIMED -// WeakSet, scheduler state, dispatcher H slot, etc. -export { flushSync, batchedUpdates as unstable_batchedUpdates } from './root' -export { createRoot, hydrateRoot } from './root' -export type { Root, RootOptions } from './root' -export { createPortal } from './portal' -export { act } from './test-utils' - -// Resource hints — stubs -export function preconnect(_href: string, _opts?: any): void {} -export function prefetchDNS(_href: string): void {} -export function preload(_href: string, _opts?: any): void {} -export function preinit(_href: string, _opts?: any): void {} -export function preloadModule(_href: string, _opts?: any): void {} -export function preinitModule(_href: string, _opts?: any): void {} - -export const version = '19.2.3' diff --git a/packages/react-dom/src/root.ts b/packages/react-dom/src/root.ts deleted file mode 100644 index 174d144..0000000 --- a/packages/react-dom/src/root.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { FiberTag, createFiber, type FiberRoot, type ReactNode } from '@tanstack/dom-core' -import { renderRoot, flushSyncWork, batchedUpdates } from './reconcile' -import { beginHydration, endHydration } from './hydration' -import { drainReplayQueue } from './event-replay' - -const GUARD_WINDOW_MS = 3000 - -function installHydrationScrollGuard(): void { - const w = window as any - if (w.__tdomScrollGuardInstalled) return - w.__tdomScrollGuardInstalled = true - const guardStartedAt = performance.now() - let lastUserScrollAt = 0 - let programmatic = 0 - w.__tdomScrollLog = [] - window.addEventListener( - 'scroll', - () => { - if (programmatic === 0) { - lastUserScrollAt = performance.now() - w.__tdomScrollLog.push({ t: Math.round(lastUserScrollAt), ev: 'user-scroll', y: window.scrollY }) - } - }, - { capture: true, passive: true }, - ) - const origScrollTo = window.scrollTo.bind(window) - window.scrollTo = function (this: any, ...args: any[]) { - const now = performance.now() - const inGuardWindow = now - guardStartedAt < GUARD_WINDOW_MS - const userScrolledRecently = lastUserScrollAt > 0 && now - lastUserScrollAt < 1500 - if (inGuardWindow && userScrolledRecently) { - w.__tdomScrollLog.push({ - t: Math.round(now), - ev: 'suppressed', - args: JSON.stringify(args).slice(0, 80), - tSinceHydrate: Math.round(now - guardStartedAt), - tSinceUserScroll: Math.round(now - lastUserScrollAt), - }) - return - } - w.__tdomScrollLog.push({ - t: Math.round(now), - ev: 'allowed', - args: JSON.stringify(args).slice(0, 80), - tSinceHydrate: Math.round(now - guardStartedAt), - inGuard: inGuardWindow, - userScrolled: userScrolledRecently, - }) - programmatic++ - try { - return (origScrollTo as any).apply(this, args) - } finally { - queueMicrotask(() => { - programmatic = Math.max(0, programmatic - 1) - }) - } - } -} - -export interface RootOptions { - identifierPrefix?: string - onRecoverableError?: (error: unknown) => void - onCaughtError?: (error: unknown) => void - onUncaughtError?: (error: unknown) => void -} - -export interface Root { - render(children: ReactNode): void - unmount(): void -} - -export function createRoot(container: Element | DocumentFragment, options: RootOptions = {}): Root { - const rootFiber = createFiber(FiberTag.Root, null, null) - rootFiber.dom = container - const root: FiberRoot = { - container, - current: rootFiber, - pending: new Set(), - scheduled: false, - onRecoverableError: options.onRecoverableError, - onCaughtError: options.onCaughtError, - onUncaughtError: options.onUncaughtError, - identifierPrefix: options.identifierPrefix ?? ':r', - hydrating: false, - } - rootFiber.root = root - rootFiber.stateNode = container - - return { - render(children) { - flushSyncWork(() => { - renderRoot(root, children) - }) - }, - unmount() { - flushSyncWork(() => { - renderRoot(root, null) - }) - }, - } -} - -export function hydrateRoot( - container: Element | Document, - initialChildren: ReactNode, - options: RootOptions = {}, -): Root { - // `container` may be the Document when the React tree renders ... - // (e.g. TanStack Start's default client entry). In that case we adopt - // documentElement as a CHILD of the root, not as the root itself — otherwise - // we'd try to render inside . - const target = container as any as Element | Document - const rootFiber = createFiber(FiberTag.Root, null, null) - rootFiber.dom = target as unknown as Node - const root: FiberRoot = { - container: target as any, - current: rootFiber, - pending: new Set(), - scheduled: false, - onRecoverableError: options.onRecoverableError, - onCaughtError: options.onCaughtError, - onUncaughtError: options.onUncaughtError, - identifierPrefix: options.identifierPrefix ?? ':r', - hydrating: false, - } - rootFiber.root = root - rootFiber.stateNode = target - - // Preserve the user's scroll position across hydration. If the user scrolled - // between SSR paint and hydrate (common in dev where JS takes seconds to - // load), libraries that wire scroll-restoration into a `useLayoutEffect` - // near the root (e.g. TanStack Router) will run during our synchronous - // hydrate and call `window.scrollTo(savedFromLastVisit)` — overwriting the - // user's fresh scroll. Snapshot scrollY before hydration; if hydration - // changes it AND the snapshot was non-zero (a strong proxy for "user - // scrolled", since `scrollRestoration = "manual"` is the common setup and - // starts at 0), restore the snapshot. Falsy snapshots pass through so - // legitimate restore-to-saved still works when the user didn't scroll. - // User-scroll-wins guard. When the user scrolls between SSR paint and the - // router's `useLayoutEffect` that invokes `window.scrollTo(savedOr0)`, the - // user's active scroll gets clobbered. The effect often fires dozens of ms - // AFTER our initial hydrate returns (subsequent `` mounts inside the - // commit microtask tail), so a snapshot/restore around hydrate alone won't - // catch it. Instead, during a short window after hydrate kicks off we track - // user-initiated scroll events and suppress any programmatic `scrollTo` - // that contradicts them. Programmatic scrolls initiated by our wrapper set - // a suppression mark so the resulting scroll event isn't misread as user - // input. Window is ~3s; after that the wrapper passes through. - if (typeof window !== 'undefined') { - installHydrationScrollGuard() - } - - beginHydration(root) - try { - flushSyncWork(() => { - renderRoot(root, initialChildren) - }) - } finally { - endHydration(root) - } - drainReplayQueue() - - return { - render(children) { - flushSyncWork(() => { - renderRoot(root, children) - }) - }, - unmount() { - flushSyncWork(() => { - renderRoot(root, null) - }) - }, - } -} - -export { flushSyncWork as flushSync, batchedUpdates } diff --git a/packages/react/package.json b/packages/react/package.json deleted file mode 100644 index 86ce5af..0000000 --- a/packages/react/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "@tanstack/react", - "version": "0.1.0-alpha.8", - "description": "Drop-in 'react' replacement for TanStack Start apps", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - }, - "./jsx-runtime": { - "types": "./src/jsx-runtime.ts", - "import": "./dist/jsx-runtime.js" - }, - "./jsx-dev-runtime": { - "types": "./src/jsx-runtime.ts", - "import": "./dist/jsx-runtime.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "dependencies": { - "@tanstack/dom-core": "0.1.0-alpha.8" - }, - "scripts": { - "build": "node ../../scripts/build.mjs" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/packages/react/src/children.d.ts b/packages/react/src/children.d.ts deleted file mode 100644 index 434e2b1..0000000 --- a/packages/react/src/children.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ReactNode, ReactElement } from '@tanstack/dom-core'; -export declare const Children: { - map(children: ReactNode, fn: (child: ReactNode, index: number) => any): any[] | null; - forEach(children: ReactNode, fn: (child: ReactNode, index: number) => void): void; - count(children: ReactNode): number; - toArray(children: ReactNode): any[]; - only(children: ReactNode): ReactElement; -}; diff --git a/packages/react/src/class.d.ts b/packages/react/src/class.d.ts deleted file mode 100644 index dc306d7..0000000 --- a/packages/react/src/class.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ReactNode } from '@tanstack/dom-core'; -type SetStateCallback = Partial | ((prev: S, props: P) => Partial | null) | null; -export declare class Component

{ - static contextType?: any; - static getDerivedStateFromProps?(props: any, state: any): any; - static getDerivedStateFromError?(error: any): any; - static defaultProps?: any; - static displayName?: string; - props: P; - state: S; - context: any; - refs: Record; - _fiber: any; - _enqueueUpdate: ((updater: SetStateCallback, cb?: () => void) => void) | null; - _forceUpdate: ((cb?: () => void) => void) | null; - constructor(props: P, context?: any); - setState(updater: SetStateCallback, callback?: () => void): void; - forceUpdate(callback?: () => void): void; - render(): ReactNode; - componentDidMount?(): void; - componentDidUpdate?(prevProps: P, prevState: S, snapshot?: any): void; - componentWillUnmount?(): void; - shouldComponentUpdate?(nextProps: P, nextState: S, nextCtx: any): boolean; - getSnapshotBeforeUpdate?(prevProps: P, prevState: S): any; - componentDidCatch?(error: any, info: { - componentStack: string; - }): void; -} -export declare class PureComponent

extends Component { -} -export {}; diff --git a/packages/react/src/context.d.ts b/packages/react/src/context.d.ts deleted file mode 100644 index e5cbafa..0000000 --- a/packages/react/src/context.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { type Context } from '@tanstack/dom-core'; -export declare function createContext(defaultValue: T): Context; diff --git a/packages/react/src/context.ts b/packages/react/src/context.ts deleted file mode 100644 index 846166a..0000000 --- a/packages/react/src/context.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - REACT_CONTEXT_TYPE, - REACT_PROVIDER_TYPE, - REACT_CONSUMER_TYPE, - type Context, -} from '@tanstack/dom-core' -import { useContext } from './hooks' - -export function createContext(defaultValue: T): Context { - const context: Context = { - $$typeof: REACT_CONTEXT_TYPE, - _currentValue: defaultValue, - } as Context - - const Provider: any = function Provider(_props: any): any { - throw new Error('Provider components are handled by the renderer.') - } - Provider.$$typeof = REACT_PROVIDER_TYPE - Provider._context = context - - const Consumer: any = function Consumer(props: { children: (v: T) => any }): any { - return props.children(useContext(context)) - } - Consumer.$$typeof = REACT_CONSUMER_TYPE - Consumer._context = context - - context.Provider = Provider - context.Consumer = Consumer - - return context -} diff --git a/packages/react/src/element.d.ts b/packages/react/src/element.d.ts deleted file mode 100644 index 494b5cc..0000000 --- a/packages/react/src/element.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type ReactElement, type ReactNode } from '@tanstack/dom-core'; -export declare const Fragment: (props: { - children?: ReactNode; -}) => ReactElement; -export declare function createElement(type: any, config: Record | null, ...children: ReactNode[]): ReactElement; -export declare function cloneElement(element: ReactElement, config: Record | null, ...children: ReactNode[]): ReactElement; -export declare function isValidElement(obj: any): obj is ReactElement; -export declare function createRef(): { - current: T | null; -}; diff --git a/packages/react/src/hooks.d.ts b/packages/react/src/hooks.d.ts deleted file mode 100644 index 03c5b72..0000000 --- a/packages/react/src/hooks.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Dispatch, SetStateAction, EffectCallback, DependencyList, Context } from '@tanstack/dom-core'; -export declare function useState(initial: S | (() => S)): [S, Dispatch>]; -export declare function useReducer(reducer: (s: S, a: A) => S, initial: any, init?: (a: any) => S): [S, Dispatch]; -export declare function useEffect(create: EffectCallback, deps?: DependencyList): void; -export declare function useLayoutEffect(create: EffectCallback, deps?: DependencyList): void; -export declare function useInsertionEffect(create: EffectCallback, deps?: DependencyList): void; -export declare function useRef(initial: T): { - current: T; -}; -export declare function useRef(initial: T | null): { - current: T | null; -}; -export declare function useRef(): { - current: T | undefined; -}; -export declare function useMemo(factory: () => T, deps?: DependencyList): T; -export declare function useCallback(fn: T, deps?: DependencyList): T; -export declare function useContext(ctx: Context): T; -export declare function useImperativeHandle(ref: any, factory: () => T, deps?: DependencyList): void; -export declare function useDebugValue(value: T, formatter?: (v: T) => any): void; -export declare function useId(): string; -export declare function useTransition(): [boolean, (fn: () => void) => void]; -export declare function useDeferredValue(v: T): T; -export declare function useSyncExternalStore(subscribe: (cb: () => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T): T; -export declare function use(resource: any): T; -export declare function startTransition(fn: () => void): void; -export declare function useActionState(_action: (state: Awaited, payload: P) => S | Promise, initial: Awaited): [Awaited, (payload: P) => void, boolean]; -export declare function useFormStatus(): { - pending: boolean; - data: any; - method: any; - action: any; -}; -export declare function useOptimistic(state: S, _updateFn?: (s: S, a: A) => S): [S, (action: A) => void]; diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts deleted file mode 100644 index d465865..0000000 --- a/packages/react/src/hooks.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { - Dispatch, - SetStateAction, - EffectCallback, - DependencyList, - Context, -} from '@tanstack/dom-core' -import { getDispatcher } from './shared-internals' - -export function useState(initial: S | (() => S)): [S, Dispatch>] { - return getDispatcher().useState(initial) -} - -export function useReducer( - reducer: (s: S, a: A) => S, - initial: any, - init?: (a: any) => S, -): [S, Dispatch] { - return getDispatcher().useReducer(reducer, initial, init) -} - -export function useEffect(create: EffectCallback, deps?: DependencyList): void { - getDispatcher().useEffect(create, deps) -} - -export function useLayoutEffect(create: EffectCallback, deps?: DependencyList): void { - getDispatcher().useLayoutEffect(create, deps) -} - -export function useInsertionEffect(create: EffectCallback, deps?: DependencyList): void { - getDispatcher().useInsertionEffect(create, deps) -} - -export function useRef(initial: T): { current: T } -export function useRef(initial: T | null): { current: T | null } -export function useRef(): { current: T | undefined } -export function useRef(initial?: any): { current: any } { - return getDispatcher().useRef(initial) -} - -export function useMemo(factory: () => T, deps?: DependencyList): T { - return getDispatcher().useMemo(factory, deps) -} - -export function useCallback(fn: T, deps?: DependencyList): T { - return getDispatcher().useCallback(fn, deps) -} - -export function useContext(ctx: Context): T { - return getDispatcher().useContext(ctx) -} - -export function useImperativeHandle( - ref: any, - factory: () => T, - deps?: DependencyList, -): void { - getDispatcher().useImperativeHandle(ref, factory, deps) -} - -export function useDebugValue(value: T, formatter?: (v: T) => any): void { - getDispatcher().useDebugValue(value, formatter) -} - -export function useId(): string { - return getDispatcher().useId() -} - -export function useTransition(): [boolean, (fn: () => void) => void] { - return getDispatcher().useTransition() -} - -export function useDeferredValue(v: T): T { - return getDispatcher().useDeferredValue(v) -} - -export function useSyncExternalStore( - subscribe: (cb: () => void) => () => void, - getSnapshot: () => T, - getServerSnapshot?: () => T, -): T { - return getDispatcher().useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) -} - -export function use(resource: any): T { - return getDispatcher().use(resource) -} - -export function startTransition(fn: () => void): void { - fn() -} - -export function useActionState( - _action: (state: Awaited, payload: P) => S | Promise, - initial: Awaited, -): [Awaited, (payload: P) => void, boolean] { - return [initial, () => {}, false] -} - -export function useFormStatus() { - return { pending: false, data: null, method: null, action: null } -} - -export function useOptimistic( - state: S, - _updateFn?: (s: S, a: A) => S, -): [S, (action: A) => void] { - return [state, () => {}] -} diff --git a/packages/react/src/index.d.ts b/packages/react/src/index.d.ts deleted file mode 100644 index 6866048..0000000 --- a/packages/react/src/index.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -export { createElement, cloneElement, isValidElement, createRef, Fragment } from './element'; -export { useState, useReducer, useEffect, useLayoutEffect, useInsertionEffect, useRef, useMemo, useCallback, useContext, useImperativeHandle, useDebugValue, useId, useTransition, useDeferredValue, useSyncExternalStore, use, useActionState, useFormStatus, useOptimistic, startTransition, } from './hooks'; -export { createContext } from './context'; -export { Component, PureComponent } from './class'; -export { memo, forwardRef, lazy } from './memo'; -export { Suspense, StrictMode, Profiler } from './suspense'; -export { Children } from './children'; -export { ReactSharedInternals } from './shared-internals'; -export declare const cache: (fn: T) => T; -export declare const act: (fn: () => any) => Promise; -export declare function taintUniqueValue(_msg: string, _lifetime: any, _value: any): void; -export declare function taintObjectReference(_msg: string, _object: any): void; -export declare const version = "19.2.3"; -import { createElement, cloneElement, isValidElement, createRef } from './element'; -import { useState, useReducer, useEffect, useLayoutEffect, useInsertionEffect, useRef, useMemo, useCallback, useContext, useImperativeHandle, useDebugValue, useId, useTransition, useDeferredValue, useSyncExternalStore, use, useActionState, useFormStatus, useOptimistic, startTransition } from './hooks'; -import { createContext } from './context'; -import { Component, PureComponent } from './class'; -import { memo, forwardRef, lazy } from './memo'; -declare const _default: { - createElement: typeof createElement; - cloneElement: typeof cloneElement; - isValidElement: typeof isValidElement; - createRef: typeof createRef; - Fragment: (props: { - children?: import("@tanstack/dom-core").ReactNode; - }) => import("@tanstack/dom-core").ReactElement; - useState: typeof useState; - useReducer: typeof useReducer; - useEffect: typeof useEffect; - useLayoutEffect: typeof useLayoutEffect; - useInsertionEffect: typeof useInsertionEffect; - useRef: typeof useRef; - useMemo: typeof useMemo; - useCallback: typeof useCallback; - useContext: typeof useContext; - useImperativeHandle: typeof useImperativeHandle; - useDebugValue: typeof useDebugValue; - useId: typeof useId; - useTransition: typeof useTransition; - useDeferredValue: typeof useDeferredValue; - useSyncExternalStore: typeof useSyncExternalStore; - use: typeof use; - useActionState: typeof useActionState; - useFormStatus: typeof useFormStatus; - useOptimistic: typeof useOptimistic; - startTransition: typeof startTransition; - createContext: typeof createContext; - Component: typeof Component; - PureComponent: typeof PureComponent; - memo: typeof memo; - forwardRef: typeof forwardRef; - lazy: typeof lazy; - Suspense: (props: { - children?: any; - fallback?: any; - }) => any; - StrictMode: (props: { - children?: any; - }) => any; - Profiler: (props: { - id: string; - onRender?: any; - children?: any; - }) => any; - Children: { - map(children: import("@tanstack/dom-core").ReactNode, fn: (child: import("@tanstack/dom-core").ReactNode, index: number) => any): any[] | null; - forEach(children: import("@tanstack/dom-core").ReactNode, fn: (child: import("@tanstack/dom-core").ReactNode, index: number) => void): void; - count(children: import("@tanstack/dom-core").ReactNode): number; - toArray(children: import("@tanstack/dom-core").ReactNode): any[]; - only(children: import("@tanstack/dom-core").ReactNode): import("@tanstack/dom-core").ReactElement; - }; - version: string; -}; -export default _default; diff --git a/packages/react/src/jsx-runtime.d.ts b/packages/react/src/jsx-runtime.d.ts deleted file mode 100644 index cead57e..0000000 --- a/packages/react/src/jsx-runtime.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type ReactElement } from '@tanstack/dom-core'; -export { Fragment } from './element'; -export declare function jsx(type: any, props: any, key?: any): ReactElement; -export declare const jsxs: typeof jsx; -export declare const jsxDEV: typeof jsx; diff --git a/packages/react/src/memo.d.ts b/packages/react/src/memo.d.ts deleted file mode 100644 index 62c32c8..0000000 --- a/packages/react/src/memo.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -export declare function memo

(type: (props: P) => any, areEqual?: (prev: Readonly

, next: Readonly

) => boolean): { - $$typeof: symbol; - type: (props: P) => any; - compare: (prev: Readonly

, next: Readonly

) => boolean; -}; -export declare function forwardRef(render: (props: P, ref: { - current: R | null; -} | ((r: R | null) => void) | null) => any): { - $$typeof: symbol; - render: (props: P, ref: { - current: R | null; - } | ((r: R | null) => void) | null) => any; -}; -export declare function lazy(ctor: () => Promise): { - $$typeof: symbol; - _payload: { - status: -1 | 0 | 1 | 2; - result: any; - }; - _init: (p: { - status: -1 | 0 | 1 | 2; - result: any; - }) => any; -}; diff --git a/packages/react/src/shared-internals.d.ts b/packages/react/src/shared-internals.d.ts deleted file mode 100644 index 0b95599..0000000 --- a/packages/react/src/shared-internals.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Fiber, FiberRoot, Hook } from '@tanstack/dom-core'; -export interface Dispatcher { - useState(initial: S | (() => S)): [S, (s: S | ((p: S) => S)) => void]; - useReducer(reducer: (s: S, a: A) => S, initial: S | any, init?: (a: any) => S): [S, (a: A) => void]; - useEffect(create: () => any, deps?: ReadonlyArray): void; - useLayoutEffect(create: () => any, deps?: ReadonlyArray): void; - useInsertionEffect(create: () => any, deps?: ReadonlyArray): void; - useRef(initial: T): { - current: T; - }; - useMemo(factory: () => T, deps?: ReadonlyArray): T; - useCallback(fn: T, deps?: ReadonlyArray): T; - useContext(ctx: any): T; - useImperativeHandle(ref: any, factory: () => T, deps?: ReadonlyArray): void; - useDebugValue(value: T, formatter?: (v: T) => any): void; - useId(): string; - useTransition(): [boolean, (fn: () => void) => void]; - useDeferredValue(v: T): T; - useSyncExternalStore(subscribe: (cb: () => void) => () => void, getSnapshot: () => T, getServerSnapshot?: () => T): T; - use(promiseOrContext: any): T; -} -interface SharedInternals { - H: Dispatcher | null; - T: any; - S: ((fn: () => void) => void) | null; - currentFiber: Fiber | null; - currentRoot: FiberRoot | null; - currentHook: Hook | null; - hookIndex: number; -} -export declare const ReactSharedInternals: SharedInternals; -export declare function getDispatcher(): Dispatcher; -export {}; diff --git a/packages/react/src/suspense.d.ts b/packages/react/src/suspense.d.ts deleted file mode 100644 index 53dc305..0000000 --- a/packages/react/src/suspense.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export declare const Suspense: (props: { - children?: any; - fallback?: any; -}) => any; -export declare const StrictMode: (props: { - children?: any; -}) => any; -export declare const Profiler: (props: { - id: string; - onRender?: any; - children?: any; -}) => any; diff --git a/packages/redact/package.json b/packages/redact/package.json new file mode 100644 index 0000000..317c980 --- /dev/null +++ b/packages/redact/package.json @@ -0,0 +1,83 @@ +{ + "name": "@tanstack/redact", + "version": "0.0.1", + "description": "React, redacted. A minimal React-API-compatible drop-in replacement.", + "type": "module", + "main": "./dist/react/index.js", + "module": "./dist/react/index.js", + "types": "./dist/react/index.d.ts", + "exports": { + ".": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js" + }, + "./jsx-runtime": { + "types": "./dist/react/jsx-runtime.d.ts", + "import": "./dist/react/jsx-runtime.js" + }, + "./jsx-dev-runtime": { + "types": "./dist/react/jsx-runtime.d.ts", + "import": "./dist/react/jsx-runtime.js" + }, + "./dom": { + "types": "./dist/dom/index.d.ts", + "import": "./dist/dom/index.js" + }, + "./dom-client": { + "types": "./dist/dom/client.d.ts", + "import": "./dist/dom/client.js" + }, + "./dom-test-utils": { + "types": "./dist/dom/test-utils.d.ts", + "import": "./dist/dom/test-utils.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./scheduler": { + "types": "./dist/scheduler/index.d.ts", + "import": "./dist/scheduler/index.js" + }, + "./vite": { + "types": "./dist/vite/index.d.ts", + "import": "./dist/vite/index.js" + }, + "./features/*": { + "types": "./dist/dom/features/*.d.ts", + "import": "./dist/dom/features/*.js" + }, + "./_all": { + "types": "./dist/dom/_all.d.ts", + "import": "./dist/dom/_all.js" + } + }, + "files": [ + "dist", + "src" + ], + "sideEffects": [ + "./src/dom/features/**", + "./dist/dom/features/**", + "./src/dom/index.ts", + "./src/dom/client.ts", + "./src/dom/_all.ts", + "./dist/dom/index.js", + "./dist/dom/client.js", + "./dist/dom/_all.js" + ], + "peerDependencies": { + "vite": ">=5" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, + "scripts": { + "build": "echo done-by-root-build" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/core/src/index.ts b/packages/redact/src/core/index.ts similarity index 100% rename from packages/core/src/index.ts rename to packages/redact/src/core/index.ts diff --git a/packages/core/src/internal.ts b/packages/redact/src/core/internal.ts similarity index 100% rename from packages/core/src/internal.ts rename to packages/redact/src/core/internal.ts diff --git a/packages/redact/src/core/types.ts b/packages/redact/src/core/types.ts new file mode 100644 index 0000000..4d1f92b --- /dev/null +++ b/packages/redact/src/core/types.ts @@ -0,0 +1,36 @@ +// React 19+ uses the transitional element symbol. When our shim's elements +// are passed to react-dom (e.g. Start routing through real react-dom/server +// somewhere), they must carry the symbol that React's isValidElement checks. +export const REACT_ELEMENT_TYPE = Symbol.for('react.transitional.element') +export const REACT_LEGACY_ELEMENT_TYPE = Symbol.for('react.element') +export const REACT_FRAGMENT_TYPE = Symbol.for('react.fragment') + +export type Key = string | number | null | undefined + +export interface ReactElement

{ + $$typeof: typeof REACT_ELEMENT_TYPE + type: T + key: string | null + ref: any + props: P +} + +export type ReactNode = + | ReactElement + | string + | number + | boolean + | null + | undefined + | Iterable + +export type RefObject = { current: T | null } +export type RefCallback = (instance: T | null) => void | (() => void) +export type Ref = RefObject | RefCallback | null + +export type Dispatch = (value: A) => void +export type SetStateAction = S | ((prev: S) => S) + +export type EffectCallback = () => void | (() => void) +export type DependencyList = ReadonlyArray + diff --git a/packages/redact/src/dom/_all.ts b/packages/redact/src/dom/_all.ts new file mode 100644 index 0000000..e7dda6d --- /dev/null +++ b/packages/redact/src/dom/_all.ts @@ -0,0 +1,54 @@ +// Unified entry exposing both top-level public API (createRoot, hydrateRoot, +// createPortal, …) and the registration primitives that custom features and +// custom bundler plugins need to hook into the reconciler. Reachable as +// `@tanstack/redact/_all` via the package's subpath exports. Single-instance +// state for things like the hydration CLAIMED WeakSet, scheduler queue, +// dispatcher H slot, etc. is preserved because every public entry funnels +// through this module. + +// Side-effect import: registers all opt-in features (renderers, type matchers, +// element markers) with the reconciler. A vite plugin may alias individual +// feature modules to ./features//stub to strip them from the bundle. +import './features' + +// Top-level public API (mirrors the surface of /, /dom, /dom-client). +export { flushSync, batchedUpdates as unstable_batchedUpdates } from './root' +export { createRoot, hydrateRoot } from './root' +export type { Root, RootOptions } from './root' +export { createPortal } from './portal' +export { act } from './test-utils' + +// Resource hints — stubs +export function preconnect(_href: string, _opts?: any): void {} +export function prefetchDNS(_href: string): void {} +export function preload(_href: string, _opts?: any): void {} +export function preinit(_href: string, _opts?: any): void {} +export function preloadModule(_href: string, _opts?: any): void {} +export function preinitModule(_href: string, _opts?: any): void {} + +export const version = '19.2.3' + +// Registration primitives — needed by custom features +// (`registerRenderer`, `registerTypeMatcher`, `registerElementMarker`) and by +// anything that wants to override a cross-cutting concern like Suspense's +// `handleSuspended` or Context's `readContext` (`installCapability`). The +// built-in features in `./features/*/{full,stub}.ts` use these via relative +// imports; external authors reach them through this entry. Order-of- +// registration matters: later calls overwrite earlier ones. +export { + registerRenderer, + registerTypeMatcher, + registerElementMarker, + installCapability, + reconcileChildren, + childrenToArray, +} from './reconcile' +export type { + RenderFn, + TypeMatcher, + Capabilities, +} from './reconcile' + +// Core types most custom features need. +export { FiberTag } from '../core' +export type { Fiber, FiberRoot, ReactNode, ReactElement } from '../core' diff --git a/packages/react-dom/src/client.ts b/packages/redact/src/dom/client.ts similarity index 82% rename from packages/react-dom/src/client.ts rename to packages/redact/src/dom/client.ts index 50e1452..f9799fe 100644 --- a/packages/react-dom/src/client.ts +++ b/packages/redact/src/dom/client.ts @@ -1,2 +1,4 @@ +import './features' + export { createRoot, hydrateRoot } from './root' export type { Root, RootOptions } from './root' diff --git a/packages/react-dom/src/dispatcher.ts b/packages/redact/src/dom/dispatcher.ts similarity index 98% rename from packages/react-dom/src/dispatcher.ts rename to packages/redact/src/dom/dispatcher.ts index 0c73d3e..0a31612 100644 --- a/packages/react-dom/src/dispatcher.ts +++ b/packages/redact/src/dom/dispatcher.ts @@ -1,5 +1,5 @@ -import type { Hook, Fiber, FiberRoot, Effect } from '@tanstack/dom-core' -import { ReactSharedInternals } from '@tanstack/react' +import type { Hook, Fiber, FiberRoot, Effect } from '../core' +import { ReactSharedInternals, REACT_CONTEXT_TYPE } from '../react' import { scheduleUpdate, enqueueEffect, readContext } from './reconcile' function getCurrentFiber(): Fiber { @@ -265,7 +265,7 @@ export function makeDispatcher() { use(resource: any): T { if (resource == null) throw new Error('use() received null or undefined') - if (resource.$$typeof === Symbol.for('react.context')) { + if (resource.$$typeof === REACT_CONTEXT_TYPE) { return readContext(getCurrentFiber(), resource) } if (typeof resource.then === 'function') { diff --git a/packages/react-dom/src/dom.ts b/packages/redact/src/dom/dom.ts similarity index 100% rename from packages/react-dom/src/dom.ts rename to packages/redact/src/dom/dom.ts diff --git a/packages/react-dom/src/event-replay.ts b/packages/redact/src/dom/event-replay.ts similarity index 100% rename from packages/react-dom/src/event-replay.ts rename to packages/redact/src/dom/event-replay.ts diff --git a/packages/redact/src/dom/features/class/full.ts b/packages/redact/src/dom/features/class/full.ts new file mode 100644 index 0000000..081376c --- /dev/null +++ b/packages/redact/src/dom/features/class/full.ts @@ -0,0 +1,104 @@ +import { FiberTag, type Fiber, type ReactNode } from '../../../core' +import { + registerRenderer, + reconcileChildren, + childrenToArray, + scheduleUpdate, + scheduleLifecycle, + isThenable, + handleSuspended, + handleErrorInRender, +} from '../../reconcile' + +function renderClass(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const Ctor = fiber.type as any + let instance = fiber.stateNode + const props = fiber.pendingProps ?? {} + const isNew = !instance + + // Class contextType: read the current value of the subscribed context so + // `this.context` reflects the nearest Provider. Evaluated every render. + const ctxValue = Ctor.contextType ? Ctor.contextType._currentValue : undefined + + if (isNew) { + instance = new Ctor(props, ctxValue) + instance.props = props + instance.context = ctxValue + instance._fiber = fiber + instance._enqueueUpdate = (updater: any, cb?: () => void) => { + const next = typeof updater === 'function' ? updater(instance.state, instance.props) : updater + if (next != null) instance.state = { ...instance.state, ...next } + if (cb) { + fiber.cleanups ||= [] + fiber.cleanups.push(cb) + } + scheduleUpdate(fiber) + } + instance._forceUpdate = (cb?: () => void) => { + if (cb) { + fiber.cleanups ||= [] + fiber.cleanups.push(cb) + } + scheduleUpdate(fiber) + } + fiber.stateNode = instance + if (Ctor.getDerivedStateFromProps) { + const d = Ctor.getDerivedStateFromProps(props, instance.state) + if (d) instance.state = { ...instance.state, ...d } + } + } else { + const prevProps = instance.props + const prevState = instance.state + // Refresh context on every render — Providers higher up may have changed. + instance.context = ctxValue + if (Ctor.getDerivedStateFromProps) { + const d = Ctor.getDerivedStateFromProps(props, instance.state) + if (d) instance.state = { ...instance.state, ...d } + } + if (instance.shouldComponentUpdate) { + if (!instance.shouldComponentUpdate(props, instance.state, instance.context)) { + instance.props = props + fiber.memoizedProps = props + // Still need to render children with previous output + if (fiber.memoizedState?.rendered) { + reconcileChildren(fiber, childrenToArray(fiber.memoizedState.rendered), domParent, anchor) + } + return + } + } + instance.props = props + // New snapshot must win over any stale one from a previous render — + // otherwise componentDidUpdate keeps seeing the original props and can + // ping-pong setState forever. + fiber.memoizedState = { ...(fiber.memoizedState ?? {}), prevProps, prevState } + } + + let rendered: ReactNode + try { + rendered = instance.render() + } catch (e: any) { + if (isThenable(e)) { + handleSuspended(fiber, e) + rendered = null + } else { + handleErrorInRender(fiber, e) + return + } + } + fiber.memoizedState = { ...(fiber.memoizedState ?? {}), rendered } + + reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) + fiber.memoizedProps = props + + // Schedule lifecycle + if (isNew) { + if (instance.componentDidMount) { + scheduleLifecycle(fiber, () => instance.componentDidMount()) + } + } else if (instance.componentDidUpdate) { + const { prevProps, prevState } = fiber.memoizedState ?? {} + scheduleLifecycle(fiber, () => instance.componentDidUpdate(prevProps, prevState)) + } +} + +registerRenderer(FiberTag.Class, renderClass) diff --git a/packages/redact/src/dom/features/class/index.ts b/packages/redact/src/dom/features/class/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/class/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/class/stub.ts b/packages/redact/src/dom/features/class/stub.ts new file mode 100644 index 0000000..abc2c97 --- /dev/null +++ b/packages/redact/src/dom/features/class/stub.ts @@ -0,0 +1,65 @@ +import { FiberTag, type Fiber, type ReactNode } from '../../../core' +import { + registerRenderer, + reconcileChildren, + childrenToArray, + scheduleUpdate, + isThenable, + handleSuspended, + handleErrorInRender, +} from '../../reconcile' + +// Stub: Class feature disabled. Class components still render — they're +// detected by `type.prototype.isReactComponent` in the reconciler regardless — +// but ONLY the core contract is honored: constructor, `render()`, and +// `setState` triggering a re-render. Dropped from this stub: +// - `contextType` (this.context is always undefined) +// - `getDerivedStateFromProps` +// - `shouldComponentUpdate` +// - `componentDidMount` / `componentDidUpdate` / `componentWillUnmount` +// - `getDerivedStateFromError` / `componentDidCatch` (error boundaries) +// Apps that actually need these should keep the feature on. Apps that just +// have a handful of legacy class components that do `render() + setState` +// still work, and save ~400 B min / ~200 B gz. +function renderClassStub(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const Ctor = fiber.type as any + let instance = fiber.stateNode + const props = fiber.pendingProps ?? {} + + if (!instance) { + instance = new Ctor(props, undefined) + instance.props = props + instance._fiber = fiber + instance._enqueueUpdate = (updater: any, cb?: () => void) => { + const next = typeof updater === 'function' ? updater(instance.state, instance.props) : updater + if (next != null) instance.state = { ...instance.state, ...next } + if (cb) { + fiber.cleanups ||= [] + fiber.cleanups.push(cb) + } + scheduleUpdate(fiber) + } + instance._forceUpdate = instance._enqueueUpdate + fiber.stateNode = instance + } else { + instance.props = props + } + + let rendered: ReactNode + try { + rendered = instance.render() + } catch (e: any) { + if (isThenable(e)) { + handleSuspended(fiber, e) + rendered = null + } else { + handleErrorInRender(fiber, e) + return + } + } + + reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) + fiber.memoizedProps = props +} + +registerRenderer(FiberTag.Class, renderClassStub) diff --git a/packages/redact/src/dom/features/context/full.ts b/packages/redact/src/dom/features/context/full.ts new file mode 100644 index 0000000..732dbf8 --- /dev/null +++ b/packages/redact/src/dom/features/context/full.ts @@ -0,0 +1,57 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_PROVIDER_TYPE, REACT_CONSUMER_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + installCapability, + reconcileChildren, + childrenToArray, +} from '../../reconcile' + +function realReadContext(fiber: Fiber, ctx: any): any { + let p: Fiber | null = fiber.parent + while (p) { + if (p.tag === FiberTag.Provider && (p.type as any)._context === ctx) { + return (p.pendingProps ?? p.memoizedProps)?.value + } + p = p.parent + } + return ctx._currentValue +} + +function renderProvider(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const ctx = (fiber.type as any)._context + const props = fiber.pendingProps ?? {} + const prevValue = ctx._currentValue + ctx._currentValue = props.value + try { + reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) + } finally { + ctx._currentValue = prevValue + } + // Also store the value on the fiber so descendants rendering later (via updates) + // can read through by walking up. + fiber.memoizedState = props.value + fiber.memoizedProps = props +} + +function renderConsumer(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const ctx = (fiber.type as any)._context + const props = fiber.pendingProps ?? {} + const children = props.children + const value = realReadContext(fiber, ctx) + const rendered = typeof children === 'function' ? children(value) : null + reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) + fiber.memoizedProps = props +} + +registerTypeMatcher((_type, marker) => + marker === REACT_PROVIDER_TYPE + ? FiberTag.Provider + : marker === REACT_CONSUMER_TYPE + ? FiberTag.Consumer + : null, +) +registerRenderer(FiberTag.Provider, renderProvider) +registerRenderer(FiberTag.Consumer, renderConsumer) +installCapability('readContext', realReadContext) diff --git a/packages/redact/src/dom/features/context/index.ts b/packages/redact/src/dom/features/context/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/context/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/context/stub.ts b/packages/redact/src/dom/features/context/stub.ts new file mode 100644 index 0000000..5495741 --- /dev/null +++ b/packages/redact/src/dom/features/context/stub.ts @@ -0,0 +1,32 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_PROVIDER_TYPE, REACT_CONSUMER_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + reconcileChildren, + childrenToArray, +} from '../../reconcile' + +// Stub: Context feature disabled. Provider elements render as Fragments +// (value is never propagated — descendants see only the Context's default). +// Consumer elements still invoke their function-children with the default +// value, so `{v => ...}` patterns keep working. +// The default `readContext` capability returns `ctx._currentValue` without +// walking, which matches the no-Provider-fibers-in-tree reality. +function renderConsumerStub(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const ctx = (fiber.type as any)._context + const props = fiber.pendingProps ?? {} + const value = ctx._currentValue + const rendered = typeof props.children === 'function' ? props.children(value) : null + reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) + fiber.memoizedProps = props +} + +registerTypeMatcher((_type, marker) => + marker === REACT_PROVIDER_TYPE + ? FiberTag.Fragment + : marker === REACT_CONSUMER_TYPE + ? FiberTag.Consumer + : null, +) +registerRenderer(FiberTag.Consumer, renderConsumerStub) diff --git a/packages/redact/src/dom/features/forward-ref/full.ts b/packages/redact/src/dom/features/forward-ref/full.ts new file mode 100644 index 0000000..d6af9f1 --- /dev/null +++ b/packages/redact/src/dom/features/forward-ref/full.ts @@ -0,0 +1,54 @@ +import { FiberTag, type Fiber, type ReactNode } from '../../../core' +import { ReactSharedInternals, REACT_FORWARD_REF_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + reconcileChildren, + childrenToArray, + isThenable, + handleSuspended, + handleErrorInRender, +} from '../../reconcile' +import { makeDispatcher } from '../../dispatcher' + +function renderForwardRef(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const props = fiber.pendingProps ?? {} + const render = (fiber.type as any).render + const ref = fiber.ref ?? (props.ref ?? null) + + const prevDispatcher = ReactSharedInternals.H + const prevFiber = ReactSharedInternals.currentFiber + const prevHook = ReactSharedInternals.currentHook + const prevIndex = ReactSharedInternals.hookIndex + ReactSharedInternals.H = makeDispatcher() + ReactSharedInternals.currentFiber = fiber + ReactSharedInternals.currentHook = null + ReactSharedInternals.hookIndex = 0 + + let rendered: ReactNode + try { + const { ref: _omit, ...rest } = props + rendered = render(rest, ref) + } catch (e: any) { + if (isThenable(e)) { + handleSuspended(fiber, e) + rendered = null + } else { + handleErrorInRender(fiber, e) + return + } + } finally { + ReactSharedInternals.H = prevDispatcher + ReactSharedInternals.currentFiber = prevFiber + ReactSharedInternals.currentHook = prevHook + ReactSharedInternals.hookIndex = prevIndex + } + + reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) + fiber.memoizedProps = props +} + +registerTypeMatcher((_type, marker) => + marker === REACT_FORWARD_REF_TYPE ? FiberTag.ForwardRef : null, +) +registerRenderer(FiberTag.ForwardRef, renderForwardRef) diff --git a/packages/redact/src/dom/features/forward-ref/index.ts b/packages/redact/src/dom/features/forward-ref/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/forward-ref/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/forward-ref/stub.ts b/packages/redact/src/dom/features/forward-ref/stub.ts new file mode 100644 index 0000000..60d8a44 --- /dev/null +++ b/packages/redact/src/dom/features/forward-ref/stub.ts @@ -0,0 +1,28 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_FORWARD_REF_TYPE } from '../../../react' +import { registerRenderer, registerTypeMatcher, renderFiber } from '../../reconcile' + +// Stub: ForwardRef feature disabled. `forwardRef(fn)` elements still render, +// but the ref prop is NOT forwarded — the component is called as a plain +// function with just props. React 19+ supports refs as normal props on +// function components, so most apps can drop forwardRef entirely; this stub +// preserves JSX compatibility while stripping the dispatcher save/restore +// machinery. +function renderForwardRefStub(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const render = (fiber.type as any).render + const savedTag = fiber.tag + const savedType = fiber.type + fiber.type = render + fiber.tag = FiberTag.Function + try { + renderFiber(fiber, domParent, anchor) + } finally { + fiber.tag = savedTag + fiber.type = savedType + } +} + +registerTypeMatcher((_type, marker) => + marker === REACT_FORWARD_REF_TYPE ? FiberTag.ForwardRef : null, +) +registerRenderer(FiberTag.ForwardRef, renderForwardRefStub) diff --git a/packages/react-dom/src/hydration.ts b/packages/redact/src/dom/features/hydration/full.ts similarity index 77% rename from packages/react-dom/src/hydration.ts rename to packages/redact/src/dom/features/hydration/full.ts index 5e8c2ef..39beeb6 100644 --- a/packages/react-dom/src/hydration.ts +++ b/packages/redact/src/dom/features/hydration/full.ts @@ -1,5 +1,75 @@ -import { FiberTag, type Fiber, type FiberRoot } from '@tanstack/dom-core' -import { setProp } from './dom' +import { FiberTag, type Fiber, type FiberRoot } from '../../../core' +import { setProp } from '../../dom' +import { findRoot } from '../../reconcile' + +// Re-export from event-replay so all hydration concerns live behind one +// feature boundary — the plugin's stub swap strips drainReplayQueue too. +export { drainReplayQueue } from '../../event-replay' + +const GUARD_WINDOW_MS = 3000 + +/** + * Preserve the user's scroll position across hydration. If the user scrolled + * between SSR paint and hydrate (common in dev where JS takes seconds to + * load), libraries that wire scroll-restoration into a `useLayoutEffect` + * near the root (e.g. TanStack Router) will run during our synchronous + * hydrate and call `window.scrollTo(savedFromLastVisit)` — overwriting the + * user's fresh scroll. We install a short-lived wrapper around scrollTo that + * suppresses programmatic calls when a user-initiated scroll happened + * recently. Only runs in the hydration feature — the stub skips it. + */ +export function installHydrationScrollGuard(): void { + if (typeof window === 'undefined') return + const w = window as any + if (w.__tdomScrollGuardInstalled) return + w.__tdomScrollGuardInstalled = true + const guardStartedAt = performance.now() + let lastUserScrollAt = 0 + let programmatic = 0 + w.__tdomScrollLog = [] + window.addEventListener( + 'scroll', + () => { + if (programmatic === 0) { + lastUserScrollAt = performance.now() + w.__tdomScrollLog.push({ t: Math.round(lastUserScrollAt), ev: 'user-scroll', y: window.scrollY }) + } + }, + { capture: true, passive: true }, + ) + const origScrollTo = window.scrollTo.bind(window) + window.scrollTo = function (this: any, ...args: any[]) { + const now = performance.now() + const inGuardWindow = now - guardStartedAt < GUARD_WINDOW_MS + const userScrolledRecently = lastUserScrollAt > 0 && now - lastUserScrollAt < 1500 + if (inGuardWindow && userScrolledRecently) { + w.__tdomScrollLog.push({ + t: Math.round(now), + ev: 'suppressed', + args: JSON.stringify(args).slice(0, 80), + tSinceHydrate: Math.round(now - guardStartedAt), + tSinceUserScroll: Math.round(now - lastUserScrollAt), + }) + return + } + w.__tdomScrollLog.push({ + t: Math.round(now), + ev: 'allowed', + args: JSON.stringify(args).slice(0, 80), + tSinceHydrate: Math.round(now - guardStartedAt), + inGuard: inGuardWindow, + userScrolled: userScrolledRecently, + }) + programmatic++ + try { + return (origScrollTo as any).apply(this, args) + } finally { + queueMicrotask(() => { + programmatic = Math.max(0, programmatic - 1) + }) + } + } +} /** * Hydration cursor: walks existing DOM children in document order so we can @@ -273,12 +343,3 @@ function onMismatch(fiber: Fiber, actualNode: ChildNode | null): void { // Remove stale DOM if still there if (actualNode && actualNode.parentNode) actualNode.parentNode.removeChild(actualNode) } - -function findRoot(fiber: Fiber): FiberRoot | null { - let f: Fiber | null = fiber - while (f) { - if (f.root) return f.root - f = f.parent - } - return null -} diff --git a/packages/redact/src/dom/features/hydration/index.ts b/packages/redact/src/dom/features/hydration/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/hydration/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/hydration/stub.ts b/packages/redact/src/dom/features/hydration/stub.ts new file mode 100644 index 0000000..7be159b --- /dev/null +++ b/packages/redact/src/dom/features/hydration/stub.ts @@ -0,0 +1,72 @@ +import type { Fiber, FiberRoot } from '../../../core' + +// Stub: Hydration feature disabled. Every adoption attempt returns "no match", +// all cursor operations no-op, and `beginHydration` throws so `hydrateRoot` +// fails loudly — apps that opt out of hydration should use `createRoot`. +// The SSR walk-the-DOM machinery, streaming-boundary coordination, head +// element matching, and the WeakMap of cursors per fiber are all stripped. + +export class HydrationCursor { + next: ChildNode | null = null + parent: Node + endBefore: ChildNode | null = null + constructor(parent?: Node, _s?: ChildNode | null, _e?: ChildNode | null) { + this.parent = parent as Node + } + takeHostNode(): ChildNode | null { + return null + } + takeMatchingHeadElement(): ChildNode | null { + return null + } + remaining(): ChildNode[] { + return [] + } +} + +export interface BoundaryInfo { + kind: 'pending' | 'resolved' + id: number + startMark: Comment + endMark: Comment +} + +export function beginHydration(_root: FiberRoot): void { + throw new Error( + '`hydrateRoot` requires the `hydration` feature. ' + + 'Enable it via @tanstack/redact/vite `features.hydration = true`, ' + + 'or use `createRoot` for a SPA (no SSR hydration).', + ) +} + +export function endHydration(_root: FiberRoot): void {} + +export function tryConsumeBoundary(_parent: Fiber): BoundaryInfo | null { + return null +} + +export function advanceCursorPast(_parent: Fiber, _node: Node): void {} + +export function getHydrationCursor(_hostFiber: Fiber): HydrationCursor | undefined { + return undefined +} + +export function setHydrationCursor(_hostFiber: Fiber, _cursor: HydrationCursor): void {} + +export function clearHydrationCursor(_hostFiber: Fiber): void {} + +export function adoptHostDom(_fiber: Fiber, _parent: Fiber): boolean { + return false +} + +export function adoptTextDom(_fiber: Fiber, _parent: Fiber, _text: string): boolean { + return false +} + +export function findHostParent(fiber: Fiber): Fiber { + return fiber +} + +export function installHydrationScrollGuard(): void {} + +export function drainReplayQueue(): void {} diff --git a/packages/redact/src/dom/features/index.ts b/packages/redact/src/dom/features/index.ts new file mode 100644 index 0000000..590c1e6 --- /dev/null +++ b/packages/redact/src/dom/features/index.ts @@ -0,0 +1,11 @@ +// Feature wiring. Each import is a side-effect import that triggers the +// feature module's self-registration with the reconciler (renderer, type +// matcher, element marker). A vite plugin can swap any entry to './stub' +// to disable that feature in the emitted bundle. +import './portal' +import './context' +import './suspense' +import './memo' +import './forward-ref' +import './lazy' +import './class' diff --git a/packages/redact/src/dom/features/lazy/full.ts b/packages/redact/src/dom/features/lazy/full.ts new file mode 100644 index 0000000..a3ca751 --- /dev/null +++ b/packages/redact/src/dom/features/lazy/full.ts @@ -0,0 +1,98 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_LAZY_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + renderFiber, + scheduleUpdate, + isThenable, + handleSuspended, + getCurrentRoot, +} from '../../reconcile' +import { + getHydrationCursor, + setHydrationCursor, + findHostParent as findHydrationHost, +} from '../hydration' + +function renderLazy(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const { _payload, _init } = fiber.type as any + let resolved: any + try { + resolved = _init(_payload) + } catch (thenable: any) { + if (isThenable(thenable)) { + // During initial hydration, a lazy component inside an SSR-resolved + // Suspense boundary needs special handling: the SSR DOM for its + // resolved content is already in the page and our cursor is pointing + // at it. A normal `handleSuspended` would schedule the lazy's later + // re-render WITHOUT the hydration cursor — so when it eventually + // resolves, we'd create a fresh DOM copy next to the SSR one (visible + // as duplicate logos / buttons / sections inside the Suspense). Mirror + // the pattern in renderFunction: preserve the in-scope cursor on this + // fiber and flag it for deferred re-hydration, so rerenderFiber + // restores `root.hydrating = true` and the resolved render adopts the + // existing DOM instead of mounting a duplicate. + const root = getCurrentRoot() + if (root?.hydrating) { + const hostParent = findHydrationHost(fiber) + const inheritedCursor = getHydrationCursor(hostParent) + if (inheritedCursor) { + setHydrationCursor(fiber, inheritedCursor) + } + fiber.memoizedState = { + ...(fiber.memoizedState ?? {}), + _pendingHydration: true, + } + // Mark the nearest ancestor Suspense as "awaiting hydration-resume" + // so a post-hydration re-render of that Suspense doesn't accidentally + // flip into suspended+pending and mount a fallback atop the SSR + // content. Match-by-tag-name works whether Suspense is the full + // feature or stubbed to Fragment (the walk just never finds one). + let sus: Fiber | null = fiber.parent + while (sus && sus.tag !== FiberTag.Suspense) sus = sus.parent + if (sus && sus.memoizedState) { + ;(sus.memoizedState as any)._awaitingLazyHydration = true + } + thenable.then( + () => { + if (sus && sus.memoizedState) { + ;(sus.memoizedState as any)._awaitingLazyHydration = false + } + scheduleUpdate(fiber) + }, + () => { + if (sus && sus.memoizedState) { + ;(sus.memoizedState as any)._awaitingLazyHydration = false + } + scheduleUpdate(fiber) + }, + ) + return + } + handleSuspended(fiber, thenable) + // reconcileChildren would be called here if we were rendering children, + // but Lazy delegates and has no children of its own. + return + } + throw thenable + } + const savedTag = fiber.tag + const savedType = fiber.type + fiber.type = resolved + fiber.tag = + typeof resolved === 'function' + ? resolved.prototype?.isReactComponent + ? FiberTag.Class + : FiberTag.Function + : FiberTag.Fragment + try { + renderFiber(fiber, domParent, anchor) + } finally { + fiber.tag = savedTag + fiber.type = savedType + } +} + +registerTypeMatcher((_type, marker) => (marker === REACT_LAZY_TYPE ? FiberTag.Lazy : null)) +registerRenderer(FiberTag.Lazy, renderLazy) diff --git a/packages/redact/src/dom/features/lazy/index.ts b/packages/redact/src/dom/features/lazy/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/lazy/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/lazy/stub.ts b/packages/redact/src/dom/features/lazy/stub.ts new file mode 100644 index 0000000..0682cad --- /dev/null +++ b/packages/redact/src/dom/features/lazy/stub.ts @@ -0,0 +1,45 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_LAZY_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + renderFiber, + isThenable, + handleSuspended, +} from '../../reconcile' + +// Stub: Lazy feature disabled. Lazy elements still resolve — sync if the +// payload is ready, otherwise the thrown thenable goes through the default +// `handleSuspended` capability (retry-on-settle). What's stripped: the +// hydration-deferred-reveal pathway and the Suspense-awaiting coordination. +function renderLazyStub(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const { _payload, _init } = fiber.type as any + let resolved: any + try { + resolved = _init(_payload) + } catch (thrown: any) { + if (isThenable(thrown)) { + handleSuspended(fiber, thrown) + return + } + throw thrown + } + const savedTag = fiber.tag + const savedType = fiber.type + fiber.type = resolved + fiber.tag = + typeof resolved === 'function' + ? resolved.prototype?.isReactComponent + ? FiberTag.Class + : FiberTag.Function + : FiberTag.Fragment + try { + renderFiber(fiber, domParent, anchor) + } finally { + fiber.tag = savedTag + fiber.type = savedType + } +} + +registerTypeMatcher((_type, marker) => (marker === REACT_LAZY_TYPE ? FiberTag.Lazy : null)) +registerRenderer(FiberTag.Lazy, renderLazyStub) diff --git a/packages/redact/src/dom/features/memo/full.ts b/packages/redact/src/dom/features/memo/full.ts new file mode 100644 index 0000000..f1cbb0f --- /dev/null +++ b/packages/redact/src/dom/features/memo/full.ts @@ -0,0 +1,74 @@ +import { FiberTag, type Fiber } from '../../../core' +import { + REACT_FORWARD_REF_TYPE, + REACT_MEMO_TYPE, + REACT_LAZY_TYPE, +} from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + renderFiber, + getForceRerenderingFiber, +} from '../../reconcile' + +function shallowEqual(a: any, b: any): boolean { + if (a === b) return true + if (!a || !b) return false + const ak = Object.keys(a) + const bk = Object.keys(b) + if (ak.length !== bk.length) return false + for (const k of ak) { + if (a[k] !== b[k]) return false + } + return true +} + +function renderMemo(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const { type, compare } = fiber.type as any + const props = fiber.pendingProps ?? {} + const prev = fiber.memoizedProps + + // Memo's prop-equality gate guards PARENT-triggered rerenders. If this + // render is a STATE-triggered rerender of this exact fiber (hook update, + // useSyncExternalStore notification), props haven't changed by definition — + // bailing would swallow the state change and the subscriber never re-runs. + // rerenderFiber tags the fiber so we skip the gate here. + const bypassMemo = fiber === getForceRerenderingFiber() + const eq = !bypassMemo && prev && (compare ? compare(prev, props) : shallowEqual(prev, props)) + if (eq) { + // Re-render children with previous output (already in tree) + return + } + + // Determine the delegated tag based on the memoized type. `React.memo` can + // wrap plain functions, class components, OR other special types like + // `forwardRef`. Without the marker-based branch we'd mistreat + // `memo(forwardRef(...))` as a Fragment and render nothing. + let innerTag: FiberTag + if (typeof type === 'function') { + innerTag = type.prototype?.isReactComponent ? FiberTag.Class : FiberTag.Function + } else if (type && typeof type === 'object') { + const m = (type as any).$$typeof + if (m === REACT_FORWARD_REF_TYPE) innerTag = FiberTag.ForwardRef + else if (m === REACT_MEMO_TYPE) innerTag = FiberTag.Memo + else if (m === REACT_LAZY_TYPE) innerTag = FiberTag.Lazy + else innerTag = FiberTag.Fragment + } else { + innerTag = FiberTag.Fragment + } + + // Swap tag and type for this render pass; this is a "delegating" render + const savedTag = fiber.tag + const savedType = fiber.type + fiber.tag = innerTag + fiber.type = type + try { + renderFiber(fiber, domParent, anchor) + } finally { + fiber.tag = savedTag + fiber.type = savedType + } +} + +registerTypeMatcher((_type, marker) => (marker === REACT_MEMO_TYPE ? FiberTag.Memo : null)) +registerRenderer(FiberTag.Memo, renderMemo) diff --git a/packages/redact/src/dom/features/memo/index.ts b/packages/redact/src/dom/features/memo/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/memo/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/memo/stub.ts b/packages/redact/src/dom/features/memo/stub.ts new file mode 100644 index 0000000..4beb5e2 --- /dev/null +++ b/packages/redact/src/dom/features/memo/stub.ts @@ -0,0 +1,42 @@ +import { FiberTag, type Fiber } from '../../../core' +import { + REACT_FORWARD_REF_TYPE, + REACT_MEMO_TYPE, + REACT_LAZY_TYPE, +} from '../../../react' +import { registerRenderer, registerTypeMatcher, renderFiber } from '../../reconcile' + +// Stub: Memo feature disabled. `memo(Component)` still works — the element +// renders — but without the prop-equality gate, so every parent rerender +// passes through to the inner component. `shallowEqual` and the +// forceRerenderingFiber read are stripped. +function renderMemoStub(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const { type } = fiber.type as any + + let innerTag: FiberTag + if (typeof type === 'function') { + innerTag = type.prototype?.isReactComponent ? FiberTag.Class : FiberTag.Function + } else if (type && typeof type === 'object') { + const m = (type as any).$$typeof + if (m === REACT_FORWARD_REF_TYPE) innerTag = FiberTag.ForwardRef + else if (m === REACT_MEMO_TYPE) innerTag = FiberTag.Memo + else if (m === REACT_LAZY_TYPE) innerTag = FiberTag.Lazy + else innerTag = FiberTag.Fragment + } else { + innerTag = FiberTag.Fragment + } + + const savedTag = fiber.tag + const savedType = fiber.type + fiber.tag = innerTag + fiber.type = type + try { + renderFiber(fiber, domParent, anchor) + } finally { + fiber.tag = savedTag + fiber.type = savedType + } +} + +registerTypeMatcher((_type, marker) => (marker === REACT_MEMO_TYPE ? FiberTag.Memo : null)) +registerRenderer(FiberTag.Memo, renderMemoStub) diff --git a/packages/redact/src/dom/features/portal/full.ts b/packages/redact/src/dom/features/portal/full.ts new file mode 100644 index 0000000..3f2f517 --- /dev/null +++ b/packages/redact/src/dom/features/portal/full.ts @@ -0,0 +1,22 @@ +import { FiberTag, type Fiber, type ReactNode } from '../../../core' +import { REACT_PORTAL_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + registerElementMarker, + reconcileChildren, + childrenToArray, +} from '../../reconcile' + +function renderPortal(fiber: Fiber, _domParent: Node, _anchor: Node | null): void { + const { children, container } = fiber.pendingProps as { + children: ReactNode + container: Element + } + reconcileChildren(fiber, childrenToArray(children), container, null) + fiber.memoizedProps = fiber.pendingProps +} + +registerElementMarker(REACT_PORTAL_TYPE) +registerTypeMatcher((type) => (type === REACT_PORTAL_TYPE ? FiberTag.Portal : null)) +registerRenderer(FiberTag.Portal, renderPortal) diff --git a/packages/redact/src/dom/features/portal/index.ts b/packages/redact/src/dom/features/portal/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/portal/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/portal/stub.ts b/packages/redact/src/dom/features/portal/stub.ts new file mode 100644 index 0000000..70cdbb0 --- /dev/null +++ b/packages/redact/src/dom/features/portal/stub.ts @@ -0,0 +1,11 @@ +import { FiberTag } from '../../../core' +import { REACT_PORTAL_TYPE } from '../../../react' +import { registerTypeMatcher, registerElementMarker } from '../../reconcile' + +// Stub: Portal feature disabled. Portal elements still flow through JSX +// (otherwise they'd be silently dropped by child normalization), but render +// in place as a Fragment — the `container` prop is ignored. No Portal +// renderer is registered, so the `renderPortal` function and its deps don't +// ship in builds that select this stub. +registerElementMarker(REACT_PORTAL_TYPE) +registerTypeMatcher((type) => (type === REACT_PORTAL_TYPE ? FiberTag.Fragment : null)) diff --git a/packages/redact/src/dom/features/suspense/full.ts b/packages/redact/src/dom/features/suspense/full.ts new file mode 100644 index 0000000..d3d51f8 --- /dev/null +++ b/packages/redact/src/dom/features/suspense/full.ts @@ -0,0 +1,191 @@ +import { FiberTag, type Fiber } from '../../../core' +import { REACT_SUSPENSE_TYPE } from '../../../react' +import { + registerRenderer, + registerTypeMatcher, + installCapability, + reconcileChildren, + childrenToArray, + scheduleUpdate, + unmountAllChildren, + findRoot, + runEffects, + getCurrentRoot, + withCurrentRoot, +} from '../../reconcile' +import { + HydrationCursor, + setHydrationCursor, + clearHydrationCursor, + advanceCursorPast, + tryConsumeBoundary, +} from '../hydration' + +const suspendHandlerStack: Array<(t: Promise) => void> = [] + +function realHandleSuspended(fiber: Fiber, thenable: Promise): void { + const handler = suspendHandlerStack[suspendHandlerStack.length - 1] + if (handler) { + handler(thenable) + return + } + // Fallback: schedule re-render when promise settles + thenable.then( + () => scheduleUpdate(fiber), + () => scheduleUpdate(fiber), + ) +} + +function renderSuspense(fiber: Fiber, domParent: Node, anchor: Node | null): void { + const props = fiber.pendingProps ?? {} + const state = (fiber.memoizedState ??= { suspended: false, pending: null as Promise | null }) + + // Streaming hydration: if the next DOM node is a server-emitted boundary + // marker, route through the boundary-aware hydration path. + const root = getCurrentRoot() + if (root?.hydrating && fiber.parent && !state.hydrated) { + const boundary = tryConsumeBoundary(fiber.parent) + if (boundary) { + hydrateSuspenseBoundary(fiber, props, boundary, domParent, anchor) + state.hydrated = true + return + } + } + + // A descendant Lazy deferred its hydration (see renderLazy's hydrating + // branch). Its SSR-rendered content is still in the DOM and cursor-bound + // via the Lazy fiber — we just haven't swapped it into a fiber subtree + // yet. Until the Lazy's resume fires, skip our own tryChildren pass so + // an unrelated re-render can't accidentally flip us into the suspended + // path and mount a duplicate fallback on top of the SSR content. + if ((state as any)._awaitingLazyHydration) { + fiber.memoizedProps = props + return + } + + const tryChildren = () => { + reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) + } + + if (state.suspended && state.pending) { + // Render fallback while waiting; pending promise will reschedule + reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) + fiber.memoizedProps = props + return + } + + // Attempt children — suspension is handled by the pushed handler below + const savedHandler = suspendHandlerStack[suspendHandlerStack.length - 1] + suspendHandlerStack.push((thenable) => { + state.suspended = true + state.pending = thenable + thenable.then( + () => { + state.suspended = false + state.pending = null + scheduleUpdate(fiber) + }, + () => { + state.suspended = false + state.pending = null + scheduleUpdate(fiber) + }, + ) + }) + try { + tryChildren() + } finally { + suspendHandlerStack.pop() + void savedHandler + } + + if (state.suspended) { + // Replace children with fallback + unmountAllChildren(fiber, domParent) + reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) + } + fiber.memoizedProps = props +} + +function hydrateSuspenseBoundary( + fiber: Fiber, + props: any, + boundary: { kind: 'pending' | 'resolved'; id: number; startMark: Comment; endMark: Comment }, + domParent: Node, + anchor: Node | null, +): void { + const { kind, id, startMark, endMark } = boundary + // Record the boundary shape so we can re-hydrate on reveal. + fiber.memoizedState = { + suspended: false, + pending: null, + hydrated: true, + boundaryId: id, + startMark, + endMark, + realChildren: props.children, + } + + if (kind === 'resolved') { + // Real DOM is inline between startMark and endMark. Hydrate into it. + const cursor = new HydrationCursor(startMark.parentNode!, startMark.nextSibling, endMark) + setHydrationCursor(fiber, cursor) + reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) + clearHydrationCursor(fiber) + advanceCursorPast(fiber.parent!, endMark) + fiber.memoizedProps = props + return + } + + // Pending: fallback DOM lives inside

. Hydrate the fallback + // React subtree against that div's children. + const bDiv = (document as Document).getElementById(`B:${id}`) + if (bDiv) { + const cursor = new HydrationCursor(bDiv) + setHydrationCursor(fiber, cursor) + reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) + clearHydrationCursor(fiber) + } else { + // Couldn't find fallback container — render fresh (non-adopting) + reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) + } + advanceCursorPast(fiber.parent!, endMark) + + // Register for server-streamed reveal (HTML chunks + $RC calls). + const win = globalThis as any + if (typeof win.$RH === 'function') { + win.$RH(id, () => rehydrateBoundary(fiber)) + } + // If the inline runtime isn't present, nothing external will mark us dirty. + + fiber.memoizedProps = props +} + +function rehydrateBoundary(fiber: Fiber): void { + const state = fiber.memoizedState + if (!state || !state.startMark || !state.endMark) return + + const root = findRoot(fiber) + if (!root) return + const parent = state.startMark.parentNode as Node + if (!parent) return + + // Unmount existing fallback subtree. Its DOM has already been removed by $RC + // (or at least its container); unmounting here cleans up fibers + effects. + withCurrentRoot(root, () => { + unmountAllChildren(fiber, parent) + + // Re-hydrate with real children against the now-real DOM range. + root.hydrating = true + const cursor = new HydrationCursor(parent, state.startMark.nextSibling, state.endMark) + setHydrationCursor(fiber, cursor) + reconcileChildren(fiber, childrenToArray(state.realChildren), parent, null) + clearHydrationCursor(fiber) + root.hydrating = false + runEffects(root) + }) +} + +registerTypeMatcher((type) => (type === REACT_SUSPENSE_TYPE ? FiberTag.Suspense : null)) +registerRenderer(FiberTag.Suspense, renderSuspense) +installCapability('handleSuspended', realHandleSuspended) diff --git a/packages/redact/src/dom/features/suspense/index.ts b/packages/redact/src/dom/features/suspense/index.ts new file mode 100644 index 0000000..007f15d --- /dev/null +++ b/packages/redact/src/dom/features/suspense/index.ts @@ -0,0 +1 @@ +export * from './full' diff --git a/packages/redact/src/dom/features/suspense/stub.ts b/packages/redact/src/dom/features/suspense/stub.ts new file mode 100644 index 0000000..de5f08f --- /dev/null +++ b/packages/redact/src/dom/features/suspense/stub.ts @@ -0,0 +1,12 @@ +import { FiberTag } from '../../../core' +import { REACT_SUSPENSE_TYPE } from '../../../react' +import { registerTypeMatcher } from '../../reconcile' + +// Stub: Suspense feature disabled. `` elements render as Fragments +// — children mount inline and `fallback` is ignored. Thrown thenables in +// descendants fall through to the default `handleSuspended` capability +// (in reconcile.ts), which schedules a re-render when the promise settles. +// Eventual consistency still works; there's just no fallback UI during the +// pending window. Boundary-handler stack, streaming hydration integration, +// and fallback-swap logic are all stripped. +registerTypeMatcher((type) => (type === REACT_SUSPENSE_TYPE ? FiberTag.Fragment : null)) diff --git a/packages/react-dom/src/index.ts b/packages/redact/src/dom/index.ts similarity index 80% rename from packages/react-dom/src/index.ts rename to packages/redact/src/dom/index.ts index 3098ed4..0384701 100644 --- a/packages/react-dom/src/index.ts +++ b/packages/redact/src/dom/index.ts @@ -1,3 +1,8 @@ +// Side-effect import: registers opt-in features (Portal, etc.) with the +// reconciler. A vite plugin may alias individual feature modules to their +// stub variants to strip them from the bundle. +import './features' + export { flushSync, batchedUpdates as unstable_batchedUpdates } from './root' export { createPortal } from './portal' diff --git a/packages/react-dom/src/portal.ts b/packages/redact/src/dom/portal.ts similarity index 75% rename from packages/react-dom/src/portal.ts rename to packages/redact/src/dom/portal.ts index 010debd..56386e2 100644 --- a/packages/react-dom/src/portal.ts +++ b/packages/redact/src/dom/portal.ts @@ -1,4 +1,5 @@ -import { REACT_PORTAL_TYPE, type ReactNode, type ReactElement } from '@tanstack/dom-core' +import type { ReactNode, ReactElement } from '../core' +import { REACT_PORTAL_TYPE } from '../react' export function createPortal( children: ReactNode, diff --git a/packages/react-dom/src/reconcile.ts b/packages/redact/src/dom/reconcile.ts similarity index 64% rename from packages/react-dom/src/reconcile.ts rename to packages/redact/src/dom/reconcile.ts index bdbdf82..41317fb 100644 --- a/packages/react-dom/src/reconcile.ts +++ b/packages/redact/src/dom/reconcile.ts @@ -5,24 +5,20 @@ import { REACT_ELEMENT_TYPE, REACT_LEGACY_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, - REACT_PROVIDER_TYPE, - REACT_CONSUMER_TYPE, - REACT_FORWARD_REF_TYPE, - REACT_MEMO_TYPE, - REACT_LAZY_TYPE, - REACT_SUSPENSE_TYPE, - REACT_STRICT_MODE_TYPE, - REACT_PROFILER_TYPE, - REACT_PORTAL_TYPE, type Fiber, type FiberRoot, type ReactElement, type ReactNode, type Hook, type Effect, -} from '@tanstack/dom-core' +} from '../core' +import { + ReactSharedInternals, + REACT_LAZY_TYPE, + REACT_STRICT_MODE_TYPE, + REACT_PROFILER_TYPE, +} from '../react' import { createHostNode, setProp } from './dom' -import { ReactSharedInternals } from '@tanstack/react' import { makeDispatcher } from './dispatcher' import { adoptHostDom, @@ -34,7 +30,7 @@ import { clearHydrationCursor, HydrationCursor, findHostParent as findHydrationHost, -} from './hydration' +} from './features/hydration' // --------------------------------------------------------------------------- // Render scheduling @@ -141,7 +137,7 @@ function fiberDepth(fiber: Fiber): number { return d } -function findRoot(fiber: Fiber): FiberRoot | null { +export function findRoot(fiber: Fiber): FiberRoot | null { let f: Fiber | null = fiber while (f) { if (f.root) return f.root @@ -229,7 +225,7 @@ function isTextChild(child: Exclude): child is TextChild return (child as any).$$typeof === undefined } -function childrenToArray(children: ReactNode): NormalizedChild[] { +export function childrenToArray(children: ReactNode): NormalizedChild[] { const out: NormalizedChild[] = [] pushChildren(children, out) return out @@ -254,15 +250,7 @@ function pushChildren(node: ReactNode, out: NormalizedChild[]): void { } if (typeof node === 'object') { const t = (node as any).$$typeof - if ( - t === REACT_ELEMENT_TYPE || - t === REACT_LEGACY_ELEMENT_TYPE || - // Portals are JSX-visible elements created by createPortal(); they carry - // REACT_PORTAL_TYPE as their $$typeof, not REACT_ELEMENT_TYPE. Dropping - // them here means Radix/Floating-UI overlays never mount (dropdowns, - // dialogs, tooltips) — silently broken. - t === REACT_PORTAL_TYPE - ) { + if (ACCEPTED_ELEMENT_MARKERS.has(t)) { out.push(node as ReactElement) return } @@ -319,19 +307,20 @@ function fiberFromChild(child: NormalizedChild, parent: Fiber): Fiber { const marker = type && (type as any).$$typeof if (typeof type === 'string') tag = FiberTag.Host else if (type === REACT_FRAGMENT_TYPE) tag = FiberTag.Fragment - else if (type === REACT_SUSPENSE_TYPE) tag = FiberTag.Suspense else if (type === REACT_STRICT_MODE_TYPE || type === REACT_PROFILER_TYPE) tag = FiberTag.Fragment - else if (marker === REACT_PROVIDER_TYPE) tag = FiberTag.Provider - else if (marker === REACT_CONSUMER_TYPE) tag = FiberTag.Consumer - else if (marker === REACT_FORWARD_REF_TYPE) tag = FiberTag.ForwardRef - else if (marker === REACT_MEMO_TYPE) tag = FiberTag.Memo - else if (marker === REACT_LAZY_TYPE) tag = FiberTag.Lazy - // Portal elements set element.type to the REACT_PORTAL_TYPE symbol directly - // (no wrapping object), so reading `.$$typeof` on the type returns undefined - // — the marker check never matches. Compare against the symbol itself. - else if (type === REACT_PORTAL_TYPE) tag = FiberTag.Portal - else if (typeof type === 'function') { - tag = type.prototype && type.prototype.isReactComponent ? FiberTag.Class : FiberTag.Function + else { + // Feature-registered type matchers (Portal, future extractions). Features + // that carry the symbol as element.type directly (rather than wrapping in + // REACT_ELEMENT_TYPE) match here by type identity. + let matched: FiberTag | null = null + for (const m of TYPE_MATCHERS) { + matched = m(type, marker) + if (matched !== null) break + } + if (matched !== null) tag = matched + else if (typeof type === 'function') { + tag = type.prototype && type.prototype.isReactComponent ? FiberTag.Class : FiberTag.Function + } } const f = createFiber(tag, type, child.key ?? null) f.ref = (child as any).ref ?? null @@ -577,28 +566,69 @@ function collectChildren(parent: Fiber): Fiber[] { // Rendering per fiber tag // --------------------------------------------------------------------------- -type RenderFn = (fiber: Fiber, domParent: Node, anchor: Node | null) => void -// Indexed by FiberTag. Relies on function-declaration hoisting: the render* -// functions below all use `function` keyword, so they're initialized before -// module code runs. -const RENDERERS: Array = (() => { - const t: Array = new Array(13) - t[FiberTag.Text] = renderText - t[FiberTag.Host] = renderHost - t[FiberTag.Function] = renderFunction - t[FiberTag.Class] = renderClass - t[FiberTag.Fragment] = renderFragment - t[FiberTag.Provider] = renderProvider - t[FiberTag.Consumer] = renderConsumer - t[FiberTag.ForwardRef] = renderForwardRef - t[FiberTag.Memo] = renderMemo - t[FiberTag.Lazy] = renderLazy - t[FiberTag.Suspense] = renderSuspense - t[FiberTag.Portal] = renderPortal - return t -})() - -function renderFiber(fiber: Fiber, domParent: Node, anchor: Node | null): void { +export type RenderFn = (fiber: Fiber, domParent: Node, anchor: Node | null) => void +export type TypeMatcher = (type: any, marker: any) => FiberTag | null + +// Mutable renderer registry indexed by FiberTag. Feature modules install their +// renderer via registerRenderer(); unregistered features render as no-ops. The +// initial registrations below rely on function-declaration hoisting — every +// render* function is declared with `function` later in this file. +const RENDERERS: Array = new Array(13) + +// Element-marker allowlist for child normalization (pushChildren). Core-always +// markers are seeded here; features add their own via registerElementMarker. +const ACCEPTED_ELEMENT_MARKERS = new Set([ + REACT_ELEMENT_TYPE as symbol, + REACT_LEGACY_ELEMENT_TYPE as symbol, +]) + +// Type-to-tag matchers tried in registration order from fiberFromChild's +// fallback branch. Features register here for element types that aren't +// marker-based (e.g. Portal, where element.type IS the symbol). +const TYPE_MATCHERS: TypeMatcher[] = [] + +export function registerRenderer(tag: FiberTag, fn: RenderFn): void { + RENDERERS[tag] = fn +} + +export function registerTypeMatcher(m: TypeMatcher): void { + TYPE_MATCHERS.push(m) +} + +export function registerElementMarker(sym: symbol): void { + ACCEPTED_ELEMENT_MARKERS.add(sym) +} + +// Accessor + scoped setter for the module-level `currentRoot`. Feature modules +// need these to participate in the render loop (e.g. Suspense re-hydration +// must temporarily set the root while rebuilding a boundary subtree). +export function getCurrentRoot(): FiberRoot | null { + return currentRoot +} + +export function withCurrentRoot(root: FiberRoot | null, fn: () => T): T { + const prev = currentRoot + currentRoot = root + try { + return fn() + } finally { + currentRoot = prev + } +} + +// The memo feature uses this to bypass its prop-equality gate on state-driven +// rerenders of the memoized fiber itself (hook update / subscribed store), +// where props haven't changed by definition. +export function getForceRerenderingFiber(): Fiber | null { + return forceRerenderingFiber +} + +registerRenderer(FiberTag.Text, renderText) +registerRenderer(FiberTag.Host, renderHost) +registerRenderer(FiberTag.Function, renderFunction) +registerRenderer(FiberTag.Fragment, renderFragment) + +export function renderFiber(fiber: Fiber, domParent: Node, anchor: Node | null): void { const fn = RENDERERS[fiber.tag] if (fn) fn(fiber, domParent, anchor) } @@ -776,7 +806,7 @@ function renderFunction(fiber: Fiber, domParent: Node, anchor: Node | null): voi e.then(clearAwait, clearAwait) deferredForHydration = true } else { - handleSuspended(fiber, e) + CAPABILITIES.handleSuspended(fiber, e) rendered = null } } else { @@ -803,99 +833,6 @@ function hasAncestorHydrationCursor(_fiber: Fiber): boolean { return false } -function renderClass(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const Ctor = fiber.type as any - let instance = fiber.stateNode - const props = fiber.pendingProps ?? {} - const isNew = !instance - - // Class contextType: read the current value of the subscribed context so - // `this.context` reflects the nearest Provider. Evaluated every render. - const ctxValue = Ctor.contextType ? Ctor.contextType._currentValue : undefined - - if (isNew) { - instance = new Ctor(props, ctxValue) - instance.props = props - instance.context = ctxValue - instance._fiber = fiber - instance._enqueueUpdate = (updater: any, cb?: () => void) => { - const next = typeof updater === 'function' ? updater(instance.state, instance.props) : updater - if (next != null) instance.state = { ...instance.state, ...next } - if (cb) { - fiber.cleanups ||= [] - fiber.cleanups.push(cb) - } - scheduleUpdate(fiber) - } - instance._forceUpdate = (cb?: () => void) => { - if (cb) { - fiber.cleanups ||= [] - fiber.cleanups.push(cb) - } - scheduleUpdate(fiber) - } - fiber.stateNode = instance - if (Ctor.getDerivedStateFromProps) { - const d = Ctor.getDerivedStateFromProps(props, instance.state) - if (d) instance.state = { ...instance.state, ...d } - } - } else { - const prevProps = instance.props - const prevState = instance.state - // Refresh context on every render — Providers higher up may have changed. - instance.context = ctxValue - if (Ctor.getDerivedStateFromProps) { - const d = Ctor.getDerivedStateFromProps(props, instance.state) - if (d) instance.state = { ...instance.state, ...d } - } - if (instance.shouldComponentUpdate) { - if (!instance.shouldComponentUpdate(props, instance.state, instance.context)) { - instance.props = props - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist - // Still need to render children with previous output - if (fiber.memoizedState?.rendered) { - reconcileChildren(fiber, childrenToArray(fiber.memoizedState.rendered), domParent, anchor) - } - return - } - } - instance.props = props - // New snapshot must win over any stale one from a previous render — - // otherwise componentDidUpdate keeps seeing the original props and can - // ping-pong setState forever. - fiber.memoizedState = { ...(fiber.memoizedState ?? {}), prevProps, prevState } - } - - let rendered: ReactNode - try { - rendered = instance.render() - } catch (e: any) { - if (isThenable(e)) { - handleSuspended(fiber, e) - rendered = null - } else { - handleErrorInRender(fiber, e) - return - } - } - fiber.memoizedState = { ...(fiber.memoizedState ?? {}), rendered } - - reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist - - // Schedule lifecycle - if (isNew) { - if (instance.componentDidMount) { - scheduleLifecycle(fiber, () => instance.componentDidMount()) - } - } else if (instance.componentDidUpdate) { - const { prevProps, prevState } = fiber.memoizedState ?? {} - scheduleLifecycle(fiber, () => instance.componentDidUpdate(prevProps, prevState)) - } -} - function renderFragment(fiber: Fiber, domParent: Node, anchor: Node | null): void { const props = fiber.pendingProps ?? {} reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) @@ -903,389 +840,53 @@ function renderFragment(fiber: Fiber, domParent: Node, anchor: Node | null): voi // dirty cleared at rerender start; leaving true lets mid-render schedule persist } -function renderProvider(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const ctx = (fiber.type as any)._context - const props = fiber.pendingProps ?? {} - const prevValue = ctx._currentValue - ctx._currentValue = props.value - try { - reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) - } finally { - ctx._currentValue = prevValue - } - // Also store the value on the fiber so descendants rendering later (via updates) - // can read through by walking up. - fiber.memoizedState = props.value - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist -} - -function renderConsumer(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const ctx = (fiber.type as any)._context - const props = fiber.pendingProps ?? {} - const children = props.children - const value = readContext(fiber, ctx) - const rendered = typeof children === 'function' ? children(value) : null - reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist -} - -function renderForwardRef(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const props = fiber.pendingProps ?? {} - const render = (fiber.type as any).render - const ref = fiber.ref ?? (props.ref ?? null) - - const prevDispatcher = ReactSharedInternals.H - const prevFiber = ReactSharedInternals.currentFiber - const prevHook = ReactSharedInternals.currentHook - const prevIndex = ReactSharedInternals.hookIndex - ReactSharedInternals.H = makeDispatcher() - ReactSharedInternals.currentFiber = fiber - ReactSharedInternals.currentHook = null - ReactSharedInternals.hookIndex = 0 - - let rendered: ReactNode - try { - const { ref: _omit, ...rest } = props - rendered = render(rest, ref) - } catch (e: any) { - if (isThenable(e)) { - handleSuspended(fiber, e) - rendered = null - } else { - handleErrorInRender(fiber, e) - return - } - } finally { - ReactSharedInternals.H = prevDispatcher - ReactSharedInternals.currentFiber = prevFiber - ReactSharedInternals.currentHook = prevHook - ReactSharedInternals.hookIndex = prevIndex - } +// --------------------------------------------------------------------------- +// Error handling + default Suspense capability +// --------------------------------------------------------------------------- - reconcileChildren(fiber, childrenToArray(rendered), domParent, anchor) - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist +// Default handler when the Suspense feature isn't installed: just schedule +// a re-render when the thrown thenable settles. No boundary walk, no +// fallback swap — children render empty during the pending window. +function defaultHandleSuspended(fiber: Fiber, thenable: Promise): void { + thenable.then( + () => scheduleUpdate(fiber), + () => scheduleUpdate(fiber), + ) } -function renderMemo(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const { type, compare } = fiber.type as any - const props = fiber.pendingProps ?? {} - const prev = fiber.memoizedProps - - // Memo's prop-equality gate guards PARENT-triggered rerenders. If this - // render is a STATE-triggered rerender of this exact fiber (hook update, - // useSyncExternalStore notification), props haven't changed by definition — - // bailing would swallow the state change and the subscriber never re-runs. - // rerenderFiber tags the fiber so we skip the gate here. - const bypassMemo = fiber === forceRerenderingFiber - const eq = !bypassMemo && prev && (compare ? compare(prev, props) : shallowEqual(prev, props)) - if (eq) { - // dirty cleared at rerender start; leaving true lets mid-render schedule persist - // Re-render children with previous output (already in tree) - return - } - - // Determine the delegated tag based on the memoized type. `React.memo` can - // wrap plain functions, class components, OR other special types like - // `forwardRef`. Without the marker-based branch we'd mistreat - // `memo(forwardRef(...))` as a Fragment and render nothing. - let innerTag: FiberTag - if (typeof type === 'function') { - innerTag = type.prototype?.isReactComponent - ? FiberTag.Class - : FiberTag.Function - } else if (type && typeof type === 'object') { - const m = (type as any).$$typeof - if (m === REACT_FORWARD_REF_TYPE) innerTag = FiberTag.ForwardRef - else if (m === REACT_MEMO_TYPE) innerTag = FiberTag.Memo - else if (m === REACT_LAZY_TYPE) innerTag = FiberTag.Lazy - else innerTag = FiberTag.Fragment - } else { - innerTag = FiberTag.Fragment - } +// --------------------------------------------------------------------------- +// Capability hooks — cross-cutting behaviors that features override. +// Defaults here preserve today's behavior so the indirection is transparent +// when all features are loaded. A feature's full-module can install its own +// implementation via installCapability(); stubs leave the default in place, +// where the default may intentionally degrade (e.g. a no-Context build's +// readContext never walks the tree because no Provider fibers exist). +// --------------------------------------------------------------------------- - // Swap tag and type for this render pass; this is a "delegating" render - const savedTag = fiber.tag - const savedType = fiber.type - fiber.tag = innerTag - fiber.type = type - try { - renderFiber(fiber, domParent, anchor) - } finally { - fiber.tag = savedTag - fiber.type = savedType - } +export interface Capabilities { + handleSuspended: (fiber: Fiber, thenable: Promise) => void + readContext: (fiber: Fiber, ctx: any) => any } -function renderLazy(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const { _payload, _init } = fiber.type as any - let resolved: any - try { - resolved = _init(_payload) - } catch (thenable: any) { - if (isThenable(thenable)) { - // During initial hydration, a lazy component inside an SSR-resolved - // Suspense boundary needs special handling: the SSR DOM for its - // resolved content is already in the page and our cursor is pointing - // at it. A normal `handleSuspended` would schedule the lazy's later - // re-render WITHOUT the hydration cursor — so when it eventually - // resolves, we'd create a fresh DOM copy next to the SSR one (visible - // as duplicate logos / buttons / sections inside the Suspense). Mirror - // the pattern in renderFunction: preserve the in-scope cursor on this - // fiber and flag it for deferred re-hydration, so rerenderFiber - // restores `root.hydrating = true` and the resolved render adopts the - // existing DOM instead of mounting a duplicate. - if (currentRoot?.hydrating) { - const hostParent = findHydrationHost(fiber) - const inheritedCursor = getHydrationCursor(hostParent) - if (inheritedCursor) { - setHydrationCursor(fiber, inheritedCursor) - } - fiber.memoizedState = { - ...(fiber.memoizedState ?? {}), - _pendingHydration: true, - } - // Mark the nearest ancestor Suspense as "awaiting hydration-resume". - // Otherwise, a subsequent post-hydration re-render of that Suspense - // (triggered by any unrelated state change) would hit `tryChildren`, - // the Lazy would re-throw, `handleSuspended` would flip Suspense into - // suspended+pending, and the fallback would be mounted ON TOP of the - // SSR-hydrated content — producing duplicate logos. By pinning state - // to a "hydration-suspended" placeholder now, the Suspense skips its - // children re-render until our Lazy's resume fires. - let sus: Fiber | null = fiber.parent - while (sus && sus.tag !== FiberTag.Suspense) sus = sus.parent - if (sus && sus.memoizedState) { - ;(sus.memoizedState as any)._awaitingLazyHydration = true - } - thenable.then( - () => { - if (sus && sus.memoizedState) { - ;(sus.memoizedState as any)._awaitingLazyHydration = false - } - scheduleUpdate(fiber) - }, - () => { - if (sus && sus.memoizedState) { - ;(sus.memoizedState as any)._awaitingLazyHydration = false - } - scheduleUpdate(fiber) - }, - ) - return - } - handleSuspended(fiber, thenable) - reconcileChildren(fiber, [], domParent, anchor) - return - } - throw thenable - } - const savedTag = fiber.tag - const savedType = fiber.type - fiber.type = resolved - fiber.tag = - typeof resolved === 'function' - ? resolved.prototype?.isReactComponent - ? FiberTag.Class - : FiberTag.Function - : FiberTag.Fragment - try { - renderFiber(fiber, domParent, anchor) - } finally { - fiber.tag = savedTag - fiber.type = savedType - } +const CAPABILITIES: Capabilities = { + handleSuspended: defaultHandleSuspended, + readContext: defaultReadContext, } -function renderSuspense(fiber: Fiber, domParent: Node, anchor: Node | null): void { - const props = fiber.pendingProps ?? {} - const state = (fiber.memoizedState ??= { suspended: false, pending: null as Promise | null }) - - // Streaming hydration: if the next DOM node is a server-emitted boundary - // marker, route through the boundary-aware hydration path. - if (currentRoot?.hydrating && fiber.parent && !state.hydrated) { - const boundary = tryConsumeBoundary(fiber.parent) - if (boundary) { - hydrateSuspenseBoundary(fiber, props, boundary, domParent, anchor) - state.hydrated = true - return - } - } - - // A descendant Lazy deferred its hydration (see renderLazy's hydrating - // branch). Its SSR-rendered content is still in the DOM and cursor-bound - // via the Lazy fiber — we just haven't swapped it into a fiber subtree - // yet. Until the Lazy's resume fires, skip our own tryChildren pass so - // an unrelated re-render can't accidentally flip us into the suspended - // path and mount a duplicate fallback on top of the SSR content. - if ((state as any)._awaitingLazyHydration) { - fiber.memoizedProps = props - return - } - - const tryChildren = () => { - reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) - } - - if (state.suspended && state.pending) { - // Render fallback while waiting; pending promise will reschedule - reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist - return - } - - // Attempt children — suspension is handled by handleSuspended setting state - const savedHandler = suspendHandlerStack[suspendHandlerStack.length - 1] - suspendHandlerStack.push((thenable) => { - state.suspended = true - state.pending = thenable - thenable.then( - () => { - state.suspended = false - state.pending = null - scheduleUpdate(fiber) - }, - () => { - state.suspended = false - state.pending = null - scheduleUpdate(fiber) - }, - ) - }) - try { - tryChildren() - } finally { - suspendHandlerStack.pop() - // restore parent handler (already done by pop) - void savedHandler - } - - if (state.suspended) { - // Replace children with fallback - unmountAllChildren(fiber, domParent) - reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) - } - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist -} - -function hydrateSuspenseBoundary( - fiber: Fiber, - props: any, - boundary: { kind: 'pending' | 'resolved'; id: number; startMark: Comment; endMark: Comment }, - domParent: Node, - anchor: Node | null, +export function installCapability( + name: K, + fn: Capabilities[K], ): void { - const { kind, id, startMark, endMark } = boundary - // Record the boundary shape so we can re-hydrate on reveal. - fiber.memoizedState = { - suspended: false, - pending: null, - hydrated: true, - boundaryId: id, - startMark, - endMark, - realChildren: props.children, - } - - if (kind === 'resolved') { - // Real DOM is inline between startMark and endMark. Hydrate into it. - const cursor = new HydrationCursor(startMark.parentNode!, startMark.nextSibling, endMark) - setHydrationCursor(fiber, cursor) - reconcileChildren(fiber, childrenToArray(props.children), domParent, anchor) - clearHydrationCursor(fiber) - advanceCursorPast(fiber.parent!, endMark) - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist - return - } - - // Pending: fallback DOM lives inside
. Hydrate the fallback - // React subtree against that div's children. - const bDiv = (document as Document).getElementById(`B:${id}`) - if (bDiv) { - const cursor = new HydrationCursor(bDiv) - setHydrationCursor(fiber, cursor) - reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) - clearHydrationCursor(fiber) - } else { - // Couldn't find fallback container — render fresh (non-adopting) - reconcileChildren(fiber, childrenToArray(props.fallback), domParent, anchor) - } - advanceCursorPast(fiber.parent!, endMark) - - // Register for server-streamed reveal (HTML chunks + $RC calls). - const win = globalThis as any - if (typeof win.$RH === 'function') { - win.$RH(id, () => rehydrateBoundary(fiber)) - } else { - // If the inline runtime isn't present, fall back to waiting on something - // external to mark us dirty (nothing to do). - } - - fiber.memoizedProps = props - // dirty cleared at rerender start; leaving true lets mid-render schedule persist -} - -function rehydrateBoundary(fiber: Fiber): void { - const state = fiber.memoizedState - if (!state || !state.startMark || !state.endMark) return - - const root = findRoot(fiber) - if (!root) return - const parent = state.startMark.parentNode as Node - if (!parent) return - - // Unmount existing fallback subtree. Its DOM has already been removed by $RC - // (or at least its container); unmounting here cleans up fibers + effects. - const prev = currentRoot - currentRoot = root - try { - unmountAllChildren(fiber, parent) - - // Re-hydrate with real children against the now-real DOM range. - root.hydrating = true - const cursor = new HydrationCursor(parent, state.startMark.nextSibling, state.endMark) - setHydrationCursor(fiber, cursor) - reconcileChildren(fiber, childrenToArray(state.realChildren), parent, null) - clearHydrationCursor(fiber) - root.hydrating = false - runEffects(root) - } finally { - currentRoot = prev - } + CAPABILITIES[name] = fn } -function renderPortal(fiber: Fiber, _domParent: Node, _anchor: Node | null): void { - const { children, container } = fiber.pendingProps as { children: ReactNode; container: Element } - reconcileChildren(fiber, childrenToArray(children), container, null) - fiber.memoizedProps = fiber.pendingProps - // dirty cleared at rerender start; leaving true lets mid-render schedule persist +// Wrapper for features that catch thrown thenables inside their render +// functions. Delegates to the installed Suspense capability. +export function handleSuspended(fiber: Fiber, thenable: Promise): void { + CAPABILITIES.handleSuspended(fiber, thenable) } -// --------------------------------------------------------------------------- -// Suspense / error handling -// --------------------------------------------------------------------------- - -const suspendHandlerStack: Array<(t: Promise) => void> = [] - -function handleSuspended(fiber: Fiber, thenable: Promise): void { - const handler = suspendHandlerStack[suspendHandlerStack.length - 1] - if (handler) { - handler(thenable) - return - } - // Fallback: schedule re-render when promise settles - thenable.then( - () => scheduleUpdate(fiber), - () => scheduleUpdate(fiber), - ) -} - -function handleErrorInRender(fiber: Fiber, err: any): void { +export function handleErrorInRender(fiber: Fiber, err: any): void { // Bubble to nearest class boundary with getDerivedStateFromError / componentDidCatch let f: Fiber | null = fiber.parent while (f) { @@ -1311,7 +912,7 @@ function handleErrorInRender(fiber: Fiber, err: any): void { else throw err } -function isThenable(x: any): x is Promise { +export function isThenable(x: any): x is Promise { return x != null && typeof x.then === 'function' } @@ -1364,7 +965,7 @@ function unmountFiber(fiber: Fiber, domParent: Node): void { } } -function unmountAllChildren(parent: Fiber, domParent: Node): void { +export function unmountAllChildren(parent: Fiber, domParent: Node): void { let c = parent.child while (c) { const next = c.sibling @@ -1443,17 +1044,19 @@ function firstDomNode(fiber: Fiber): Node | null { } // --------------------------------------------------------------------------- -// Context read (for useContext — walk up for nearest Provider) +// Context read — exported for dispatcher.ts (useContext, use()). Delegates to +// the installed capability so the Context feature can override with a walking +// implementation that finds the nearest Provider fiber. When the feature is +// stubbed, the default here returns ctx._currentValue — correct because no +// Provider fibers exist in the tree (Provider element → Fragment via the +// stub's type matcher). // --------------------------------------------------------------------------- export function readContext(fiber: Fiber, ctx: any): any { - let p: Fiber | null = fiber.parent - while (p) { - if (p.tag === FiberTag.Provider && (p.type as any)._context === ctx) { - return (p.pendingProps ?? p.memoizedProps)?.value - } - p = p.parent - } + return CAPABILITIES.readContext(fiber, ctx) +} + +function defaultReadContext(_fiber: Fiber, ctx: any): any { return ctx._currentValue } @@ -1510,7 +1113,7 @@ export function enqueueEffect(fiber: Fiber, effect: Effect): void { } } -function scheduleLifecycle(fiber: Fiber, fn: () => void): void { +export function scheduleLifecycle(fiber: Fiber, fn: () => void): void { pendingLifecycles.push({ fiber, fn }) } @@ -1564,14 +1167,3 @@ function isEventProp(name: string): boolean { ) } -function shallowEqual(a: any, b: any): boolean { - if (a === b) return true - if (!a || !b) return false - const ak = Object.keys(a) - const bk = Object.keys(b) - if (ak.length !== bk.length) return false - for (const k of ak) { - if (a[k] !== b[k]) return false - } - return true -} diff --git a/packages/redact/src/dom/root.ts b/packages/redact/src/dom/root.ts new file mode 100644 index 0000000..05a8c78 --- /dev/null +++ b/packages/redact/src/dom/root.ts @@ -0,0 +1,107 @@ +import { FiberTag, createFiber, type FiberRoot, type ReactNode } from '../core' +import { renderRoot, flushSyncWork, batchedUpdates } from './reconcile' +import { + beginHydration, + endHydration, + drainReplayQueue, + installHydrationScrollGuard, +} from './features/hydration' + +export interface RootOptions { + identifierPrefix?: string + onRecoverableError?: (error: unknown) => void + onCaughtError?: (error: unknown) => void + onUncaughtError?: (error: unknown) => void +} + +export interface Root { + render(children: ReactNode): void + unmount(): void +} + +export function createRoot(container: Element | DocumentFragment, options: RootOptions = {}): Root { + const rootFiber = createFiber(FiberTag.Root, null, null) + rootFiber.dom = container + const root: FiberRoot = { + container, + current: rootFiber, + pending: new Set(), + scheduled: false, + onRecoverableError: options.onRecoverableError, + onCaughtError: options.onCaughtError, + onUncaughtError: options.onUncaughtError, + identifierPrefix: options.identifierPrefix ?? ':r', + hydrating: false, + } + rootFiber.root = root + rootFiber.stateNode = container + + return { + render(children) { + flushSyncWork(() => { + renderRoot(root, children) + }) + }, + unmount() { + flushSyncWork(() => { + renderRoot(root, null) + }) + }, + } +} + +export function hydrateRoot( + container: Element | Document, + initialChildren: ReactNode, + options: RootOptions = {}, +): Root { + // `container` may be the Document when the React tree renders ... + // (e.g. TanStack Start's default client entry). In that case we adopt + // documentElement as a CHILD of the root, not as the root itself — otherwise + // we'd try to render inside . + const target = container as any as Element | Document + const rootFiber = createFiber(FiberTag.Root, null, null) + rootFiber.dom = target as unknown as Node + const root: FiberRoot = { + container: target as any, + current: rootFiber, + pending: new Set(), + scheduled: false, + onRecoverableError: options.onRecoverableError, + onCaughtError: options.onCaughtError, + onUncaughtError: options.onUncaughtError, + identifierPrefix: options.identifierPrefix ?? ':r', + hydrating: false, + } + rootFiber.root = root + rootFiber.stateNode = target + + // Preserve the user's scroll position across hydration (see feature impl + // for the details). No-op in SSR; no-op in the stub. + installHydrationScrollGuard() + + beginHydration(root) + try { + flushSyncWork(() => { + renderRoot(root, initialChildren) + }) + } finally { + endHydration(root) + } + drainReplayQueue() + + return { + render(children) { + flushSyncWork(() => { + renderRoot(root, children) + }) + }, + unmount() { + flushSyncWork(() => { + renderRoot(root, null) + }) + }, + } +} + +export { flushSyncWork as flushSync, batchedUpdates } diff --git a/packages/react-dom/src/test-utils.ts b/packages/redact/src/dom/test-utils.ts similarity index 100% rename from packages/react-dom/src/test-utils.ts rename to packages/redact/src/dom/test-utils.ts diff --git a/packages/react/src/children.ts b/packages/redact/src/react/children.ts similarity index 97% rename from packages/react/src/children.ts rename to packages/redact/src/react/children.ts index 188c7c1..f8d0c10 100644 --- a/packages/react/src/children.ts +++ b/packages/redact/src/react/children.ts @@ -1,5 +1,5 @@ import { isValidElement, cloneElement } from './element' -import type { ReactNode, ReactElement } from '@tanstack/dom-core' +import type { ReactNode, ReactElement } from '../core' function flatten(node: ReactNode, out: any[], prefix: string): void { if (node == null || typeof node === 'boolean') return diff --git a/packages/react/src/class.ts b/packages/redact/src/react/class.ts similarity index 97% rename from packages/react/src/class.ts rename to packages/redact/src/react/class.ts index 3bfc2df..acf7933 100644 --- a/packages/react/src/class.ts +++ b/packages/redact/src/react/class.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from '@tanstack/dom-core' +import type { ReactNode } from '../core' type SetStateCallback = | Partial diff --git a/packages/redact/src/react/context.ts b/packages/redact/src/react/context.ts new file mode 100644 index 0000000..2ac7466 --- /dev/null +++ b/packages/redact/src/react/context.ts @@ -0,0 +1,50 @@ +import type { ReactElement, ReactNode } from '../core' +import { useContext } from './hooks' + +export const REACT_CONTEXT_TYPE = Symbol.for('react.context') +export const REACT_PROVIDER_TYPE = Symbol.for('react.provider') +export const REACT_CONSUMER_TYPE = Symbol.for('react.consumer') + +export interface Context { + $$typeof: typeof REACT_CONTEXT_TYPE + _currentValue: T + Provider: ProviderExoticComponent<{ value: T; children?: ReactNode }> + Consumer: ConsumerExoticComponent + displayName?: string +} + +export interface ProviderExoticComponent

{ + $$typeof: typeof REACT_PROVIDER_TYPE + _context: Context + (props: P): ReactElement +} + +export interface ConsumerExoticComponent { + $$typeof: typeof REACT_CONSUMER_TYPE + _context: Context + (props: { children: (value: T) => ReactNode }): ReactElement +} + +export function createContext(defaultValue: T): Context { + const context: Context = { + $$typeof: REACT_CONTEXT_TYPE, + _currentValue: defaultValue, + } as Context + + const Provider: any = function Provider(_props: any): any { + throw new Error('Provider components are handled by the renderer.') + } + Provider.$$typeof = REACT_PROVIDER_TYPE + Provider._context = context + + const Consumer: any = function Consumer(props: { children: (v: T) => any }): any { + return props.children(useContext(context)) + } + Consumer.$$typeof = REACT_CONSUMER_TYPE + Consumer._context = context + + context.Provider = Provider + context.Consumer = Consumer + + return context +} diff --git a/packages/react/src/element.ts b/packages/redact/src/react/element.ts similarity index 98% rename from packages/react/src/element.ts rename to packages/redact/src/react/element.ts index 19175cd..1d72bd6 100644 --- a/packages/react/src/element.ts +++ b/packages/redact/src/react/element.ts @@ -4,7 +4,7 @@ import { REACT_FRAGMENT_TYPE, type ReactElement, type ReactNode, -} from '@tanstack/dom-core' +} from '../core' export const Fragment = REACT_FRAGMENT_TYPE as unknown as (props: { children?: ReactNode diff --git a/packages/redact/src/react/hooks.ts b/packages/redact/src/react/hooks.ts new file mode 100644 index 0000000..81c96b7 --- /dev/null +++ b/packages/redact/src/react/hooks.ts @@ -0,0 +1,190 @@ +import type { + Dispatch, + SetStateAction, + EffectCallback, + DependencyList, +} from '../core' +import type { Context } from './context' +import { getDispatcher } from './shared-internals' + +export function useState(initial: S | (() => S)): [S, Dispatch>] { + return getDispatcher().useState(initial) +} + +export function useReducer( + reducer: (s: S, a: A) => S, + initial: any, + init?: (a: any) => S, +): [S, Dispatch] { + return getDispatcher().useReducer(reducer, initial, init) +} + +export function useEffect(create: EffectCallback, deps?: DependencyList): void { + getDispatcher().useEffect(create, deps) +} + +export function useLayoutEffect(create: EffectCallback, deps?: DependencyList): void { + getDispatcher().useLayoutEffect(create, deps) +} + +export function useInsertionEffect(create: EffectCallback, deps?: DependencyList): void { + getDispatcher().useInsertionEffect(create, deps) +} + +export function useRef(initial: T): { current: T } +export function useRef(initial: T | null): { current: T | null } +export function useRef(): { current: T | undefined } +export function useRef(initial?: any): { current: any } { + return getDispatcher().useRef(initial) +} + +export function useMemo(factory: () => T, deps?: DependencyList): T { + return getDispatcher().useMemo(factory, deps) +} + +export function useCallback(fn: T, deps?: DependencyList): T { + return getDispatcher().useCallback(fn, deps) +} + +export function useContext(ctx: Context): T { + return getDispatcher().useContext(ctx) +} + +export function useImperativeHandle( + ref: any, + factory: () => T, + deps?: DependencyList, +): void { + getDispatcher().useImperativeHandle(ref, factory, deps) +} + +export function useDebugValue(value: T, formatter?: (v: T) => any): void { + getDispatcher().useDebugValue(value, formatter) +} + +export function useId(): string { + return getDispatcher().useId() +} + +export function useTransition(): [boolean, (fn: () => void) => void] { + return getDispatcher().useTransition() +} + +export function useDeferredValue(v: T): T { + return getDispatcher().useDeferredValue(v) +} + +export function useSyncExternalStore( + subscribe: (cb: () => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, +): T { + return getDispatcher().useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +export function use(resource: any): T { + return getDispatcher().use(resource) +} + +export function startTransition(fn: () => void): void { + fn() +} + +export function useActionState( + _action: (state: Awaited, payload: P) => S | Promise, + initial: Awaited, +): [Awaited, (payload: P) => void, boolean] { + return [initial, () => {}, false] +} + +export function useFormStatus() { + return { pending: false, data: null, method: null, action: null } +} + +export function useOptimistic( + state: S, + _updateFn?: (s: S, a: A) => S, +): [S, (action: A) => void] { + return [state, () => {}] +} + +// Composed hook — returns a stable callback that always invokes the latest +// `fn`. The ref is updated in useInsertionEffect so any useLayoutEffect / +// useEffect reading ref.current in the next commit sees the fresh function. +export function useEffectEvent( + fn: (...args: Args) => Return, +): (...args: Args) => Return { + const ref = useRef(fn) + useInsertionEffect(() => { + ref.current = fn + }) + return useCallback( + ((...args: Args) => ref.current(...args)) as (...args: Args) => Return, + [], + ) +} + +// `useSyncExternalStoreWithSelector` — provided here so consumers (like +// @tanstack/react-store) that historically import from +// `use-sync-external-store/shim/with-selector` can be aliased to this +// package via the @tanstack/redact/vite plugin. The CJS shim is unsuitable +// for Cloudflare Workers (it does `var React = require('react')` which +// fails in workerd). Implementation matches React's reference impl — +// selector + isEqual gate to avoid re-renders when the selected slice is +// unchanged across snapshots. +export function useSyncExternalStoreWithSelector( + subscribe: (cb: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot: (() => Snapshot) | undefined, + selector: (snapshot: Snapshot) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean, +): Selection { + const instRef = useRef<{ hasValue: boolean; value: Selection | null } | null>(null) + let inst: { hasValue: boolean; value: Selection | null } + if (instRef.current === null) { + inst = { hasValue: false, value: null } + instRef.current = inst + } else { + inst = instRef.current + } + + const [getSelection, getServerSelection] = useMemo(() => { + let hasMemo = false + let memoizedSnapshot: Snapshot + let memoizedSelection: Selection + const memoizedSelector = (nextSnapshot: Snapshot): Selection => { + if (!hasMemo) { + hasMemo = true + memoizedSnapshot = nextSnapshot + const nextSelection = selector(nextSnapshot) + if (isEqual !== undefined && inst.hasValue) { + const currentSelection = inst.value as Selection + if (isEqual(currentSelection, nextSelection)) { + memoizedSelection = currentSelection + return currentSelection + } + } + memoizedSelection = nextSelection + return nextSelection + } + const prevSnapshot: Snapshot = memoizedSnapshot + const prevSelection: Selection = memoizedSelection + if (Object.is(prevSnapshot, nextSnapshot)) return prevSelection + const nextSelection = selector(nextSnapshot) + if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) return prevSelection + memoizedSnapshot = nextSnapshot + memoizedSelection = nextSelection + return nextSelection + } + const get = () => memoizedSelector(getSnapshot()) + const getServer = + getServerSnapshot === undefined ? undefined : () => memoizedSelector(getServerSnapshot()) + return [get, getServer] as const + }, [getSnapshot, getServerSnapshot, selector, isEqual]) + + const value = useSyncExternalStore(subscribe, getSelection, getServerSelection) + useDebugValue(value) + inst.hasValue = true + inst.value = value + return value +} diff --git a/packages/react/src/index.ts b/packages/redact/src/react/index.ts similarity index 79% rename from packages/react/src/index.ts rename to packages/redact/src/react/index.ts index e465102..1303bb4 100644 --- a/packages/react/src/index.ts +++ b/packages/redact/src/react/index.ts @@ -15,16 +15,37 @@ export { useTransition, useDeferredValue, useSyncExternalStore, + useSyncExternalStoreWithSelector, use, useActionState, useFormStatus, useOptimistic, + useEffectEvent, startTransition, } from './hooks' -export { createContext } from './context' +export { + createContext, + REACT_CONTEXT_TYPE, + REACT_PROVIDER_TYPE, + REACT_CONSUMER_TYPE, + type Context, + type ProviderExoticComponent, + type ConsumerExoticComponent, +} from './context' export { Component, PureComponent } from './class' -export { memo, forwardRef, lazy } from './memo' -export { Suspense, StrictMode, Profiler } from './suspense' +export { + memo, forwardRef, lazy, + REACT_MEMO_TYPE, + REACT_FORWARD_REF_TYPE, + REACT_LAZY_TYPE, +} from './memo' +export { + Suspense, StrictMode, Profiler, + REACT_SUSPENSE_TYPE, + REACT_STRICT_MODE_TYPE, + REACT_PROFILER_TYPE, +} from './suspense' +export { REACT_PORTAL_TYPE } from './portal' export { Children } from './children' export { ReactSharedInternals } from './shared-internals' @@ -61,6 +82,7 @@ import { useActionState, useFormStatus, useOptimistic, + useEffectEvent, startTransition, } from './hooks' import { createContext } from './context' @@ -94,6 +116,7 @@ export default { useActionState, useFormStatus, useOptimistic, + useEffectEvent, startTransition, createContext, Component, diff --git a/packages/react/src/jsx-runtime.ts b/packages/redact/src/react/jsx-runtime.ts similarity index 80% rename from packages/react/src/jsx-runtime.ts rename to packages/redact/src/react/jsx-runtime.ts index 4acdf1d..2cb0242 100644 --- a/packages/react/src/jsx-runtime.ts +++ b/packages/redact/src/react/jsx-runtime.ts @@ -1,4 +1,4 @@ -import { REACT_ELEMENT_TYPE, type ReactElement } from '@tanstack/dom-core' +import { REACT_ELEMENT_TYPE, type ReactElement } from '../core' export { Fragment } from './element' diff --git a/packages/react/src/memo.ts b/packages/redact/src/react/memo.ts similarity index 85% rename from packages/react/src/memo.ts rename to packages/redact/src/react/memo.ts index d05d2e3..5174019 100644 --- a/packages/react/src/memo.ts +++ b/packages/redact/src/react/memo.ts @@ -1,4 +1,6 @@ -import { REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE, REACT_LAZY_TYPE } from '@tanstack/dom-core' +export const REACT_MEMO_TYPE = Symbol.for('react.memo') +export const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref') +export const REACT_LAZY_TYPE = Symbol.for('react.lazy') export function memo

( type: (props: P) => any, diff --git a/packages/redact/src/react/portal.ts b/packages/redact/src/react/portal.ts new file mode 100644 index 0000000..0431ded --- /dev/null +++ b/packages/redact/src/react/portal.ts @@ -0,0 +1 @@ +export const REACT_PORTAL_TYPE = Symbol.for('react.portal') diff --git a/packages/react/src/shared-internals.ts b/packages/redact/src/react/shared-internals.ts similarity index 62% rename from packages/react/src/shared-internals.ts rename to packages/redact/src/react/shared-internals.ts index b54a856..493fbb4 100644 --- a/packages/react/src/shared-internals.ts +++ b/packages/redact/src/react/shared-internals.ts @@ -1,4 +1,4 @@ -import type { Fiber, FiberRoot, Hook } from '@tanstack/dom-core' +import type { Fiber, FiberRoot, Hook } from '../core' export interface Dispatcher { useState(initial: S | (() => S)): [S, (s: S | ((p: S) => S)) => void] @@ -37,15 +37,30 @@ interface SharedInternals { hookIndex: number } -export const ReactSharedInternals: SharedInternals = { - H: null, - T: null, - S: null, - currentFiber: null, - currentRoot: null, - currentHook: null, - hookIndex: 0, -} +// Stash the singleton on `globalThis` under a registered symbol. Module-scoped +// state goes wrong fast in environments that end up with multiple copies of +// `@tanstack/redact` in flight — most notably Cloudflare's `vite-plugin` dev +// mode, where the worker entry inlines `@tanstack/redact` once via `noExternal` +// while user code reaches a separate pre-bundled `deps_ssr/redact.js` copy. +// Each copy would otherwise have its own `ReactSharedInternals.H`, so the SSR +// dispatcher installed by one would be invisible to hooks called through the +// other and `useContext` would explode with "Hooks can only be called inside a +// function component". `Symbol.for` survives module re-evaluation and isolate +// boundaries, giving every copy the same backing object. +const KEY = Symbol.for('@tanstack/redact.ReactSharedInternals') +const g = globalThis as unknown as { [k: symbol]: SharedInternals | undefined } + +export const ReactSharedInternals: SharedInternals = + g[KEY] ?? + (g[KEY] = { + H: null, + T: null, + S: null, + currentFiber: null, + currentRoot: null, + currentHook: null, + hookIndex: 0, + }) export function getDispatcher(): Dispatcher { const d = ReactSharedInternals.H diff --git a/packages/react/src/suspense.ts b/packages/redact/src/react/suspense.ts similarity index 62% rename from packages/react/src/suspense.ts rename to packages/redact/src/react/suspense.ts index f55f6b7..22bcd89 100644 --- a/packages/react/src/suspense.ts +++ b/packages/redact/src/react/suspense.ts @@ -1,4 +1,6 @@ -import { REACT_SUSPENSE_TYPE, REACT_STRICT_MODE_TYPE, REACT_PROFILER_TYPE } from '@tanstack/dom-core' +export const REACT_SUSPENSE_TYPE = Symbol.for('react.suspense') +export const REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode') +export const REACT_PROFILER_TYPE = Symbol.for('react.profiler') export const Suspense = REACT_SUSPENSE_TYPE as any as (props: { children?: any diff --git a/packages/scheduler/src/index.ts b/packages/redact/src/scheduler/index.ts similarity index 100% rename from packages/scheduler/src/index.ts rename to packages/redact/src/scheduler/index.ts diff --git a/packages/react-dom-server/src/bootstrap-script.ts b/packages/redact/src/server/bootstrap-script.ts similarity index 99% rename from packages/react-dom-server/src/bootstrap-script.ts rename to packages/redact/src/server/bootstrap-script.ts index f0454b4..bdaee50 100644 --- a/packages/react-dom-server/src/bootstrap-script.ts +++ b/packages/redact/src/server/bootstrap-script.ts @@ -15,7 +15,7 @@ * * Early-event buffering: before the main bundle is parsed, we attach capture * listeners for a small set of interactive events and push them into a buffer. - * The bundle drains and replays them via the replay code in @tanstack/react-dom. + * The bundle drains and replays them via the replay code in @tanstack/redact/dom. */ export const BOUNDARY_REVEAL_RUNTIME = `(function(){` + diff --git a/packages/react-dom-server/src/dispatcher.ts b/packages/redact/src/server/dispatcher.ts similarity index 97% rename from packages/react-dom-server/src/dispatcher.ts rename to packages/redact/src/server/dispatcher.ts index ac3a234..1d9faee 100644 --- a/packages/react-dom-server/src/dispatcher.ts +++ b/packages/redact/src/server/dispatcher.ts @@ -1,4 +1,4 @@ -import { ReactSharedInternals } from '@tanstack/react' +import { ReactSharedInternals, REACT_CONTEXT_TYPE } from '../react' interface SSRFrame { idCounter: number @@ -113,7 +113,7 @@ export const ssrDispatcher = { }, use(resource: any): T { if (resource == null) throw new Error('use() received null or undefined') - if (resource.$$typeof === Symbol.for('react.context')) { + if (resource.$$typeof === REACT_CONTEXT_TYPE) { return resource._currentValue } if (typeof resource.then === 'function') { diff --git a/packages/react-dom-server/src/escape.ts b/packages/redact/src/server/escape.ts similarity index 100% rename from packages/react-dom-server/src/escape.ts rename to packages/redact/src/server/escape.ts diff --git a/packages/react-dom-server/src/index.ts b/packages/redact/src/server/index.ts similarity index 100% rename from packages/react-dom-server/src/index.ts rename to packages/redact/src/server/index.ts diff --git a/packages/react-dom-server/src/renderToString.ts b/packages/redact/src/server/renderToString.ts similarity index 94% rename from packages/react-dom-server/src/renderToString.ts rename to packages/redact/src/server/renderToString.ts index a621ad2..ba44db0 100644 --- a/packages/react-dom-server/src/renderToString.ts +++ b/packages/redact/src/server/renderToString.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from '@tanstack/dom-core' +import type { ReactNode } from '../core' import { beginSSR, endSSR, installSSRDispatcher, uninstallSSRDispatcher } from './dispatcher' import { walk } from './walk' diff --git a/packages/react-dom-server/src/stream.ts b/packages/redact/src/server/stream.ts similarity index 99% rename from packages/react-dom-server/src/stream.ts rename to packages/redact/src/server/stream.ts index e06f92f..ab7ccef 100644 --- a/packages/react-dom-server/src/stream.ts +++ b/packages/redact/src/server/stream.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from '@tanstack/dom-core' +import type { ReactNode } from '../core' import { beginSSR, endSSR, diff --git a/packages/react-dom-server/src/walk.ts b/packages/redact/src/server/walk.ts similarity index 99% rename from packages/react-dom-server/src/walk.ts rename to packages/redact/src/server/walk.ts index 6cf34bc..419537e 100644 --- a/packages/react-dom-server/src/walk.ts +++ b/packages/redact/src/server/walk.ts @@ -2,6 +2,10 @@ import { REACT_ELEMENT_TYPE, REACT_LEGACY_ELEMENT_TYPE, REACT_FRAGMENT_TYPE, + type ReactNode, + type ReactElement, +} from '../core' +import { REACT_SUSPENSE_TYPE, REACT_PROVIDER_TYPE, REACT_CONSUMER_TYPE, @@ -11,9 +15,7 @@ import { REACT_STRICT_MODE_TYPE, REACT_PROFILER_TYPE, REACT_PORTAL_TYPE, - type ReactNode, - type ReactElement, -} from '@tanstack/dom-core' +} from '../react' import { attrToHtml, escapeText, diff --git a/packages/redact/src/vite/index.ts b/packages/redact/src/vite/index.ts new file mode 100644 index 0000000..1062e1e --- /dev/null +++ b/packages/redact/src/vite/index.ts @@ -0,0 +1,401 @@ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { dirname, resolve as resolvePath } from 'node:path' +import { fileURLToPath } from 'node:url' + +export type RedactPreset = 'nano' | 'full' + +/** + * Opt-in feature set. Each flag toggles whether the feature's real + * implementation ships (`true`) or is swapped with a stub module that + * degrades gracefully (`false`). Missing keys fall back to the preset's + * default. Adding a feature to this interface propagates to consumer + * configs as autocompleted options. + */ +export interface RedactFeatures { + /** + * `createPortal`. When `false`, portal elements render in place as a + * Fragment (the `container` prop is ignored). `renderPortal` and its + * deps are stripped from the bundle. + */ + portal?: boolean + /** + * `createContext` / `useContext` / `` / ``. When + * `false`, Providers render as Fragments (value never propagates), + * Consumers invoke their function-children with the context's default + * value, and `useContext` returns the default. Provider-walk logic and + * `renderProvider` are stripped. + */ + context?: boolean + /** + * `` boundaries + streaming hydration. When `false`, Suspense + * elements render as Fragments (children mount inline, `fallback` is + * ignored). Thrown thenables still schedule a re-render on settle, so + * eventual consistency works — just no fallback UI during the pending + * window. Boundary-handler stack and hydration integration are stripped. + */ + suspense?: boolean + /** + * `React.memo`. When `false`, memoized components still render but without + * the prop-equality gate — every parent rerender passes through. + * `shallowEqual` and the force-rerender bypass are stripped. + */ + memo?: boolean + /** + * `React.forwardRef`. When `false`, forwardRef components still render but + * the ref prop isn't forwarded to the inner function. React 19+ treats + * refs as normal props on function components anyway, so most apps can + * drop this. The dispatcher save/restore machinery is stripped. + */ + forwardRef?: boolean + /** + * `React.lazy`. When `false`, lazy elements still resolve if their payload + * is already available synchronously (e.g. pre-awaited RSC Flight); async + * resolution throws a clear error. The hydration-deferred-reveal path and + * Suspense coordination are stripped. + */ + lazy?: boolean + /** + * Class components (`extends Component`). When `false`, class components + * still render but only honor the core contract: constructor + `render()` + * + `setState`. Dropped: `contextType`, `getDerivedStateFromProps`, + * `shouldComponentUpdate`, `componentDidMount`/`Update`/`WillUnmount`, + * `getDerivedStateFromError`/`componentDidCatch` (error boundaries). + */ + classComponents?: boolean + /** + * SSR hydration (`hydrateRoot`). When `false`, `hydrateRoot` throws + * (use `createRoot` for SPAs). The HydrationCursor / DOM adoption / + * streaming-boundary coordination / event-replay / scroll-guard + * machinery is stripped — the biggest single chunk of reducible code. + */ + hydration?: boolean +} + +interface ResolvedFeatures { + portal: boolean + context: boolean + suspense: boolean + memo: boolean + forwardRef: boolean + lazy: boolean + classComponents: boolean + hydration: boolean +} + +const PRESET_DEFAULTS: Record = { + // Opt-in: everything off. Turn individual features on via `features`. + nano: { + portal: false, context: false, suspense: false, memo: false, + forwardRef: false, lazy: false, classComponents: false, hydration: false, + }, + // Opt-out: everything on (drop-in React parity). Turn features off via `features`. + full: { + portal: true, context: true, suspense: true, memo: true, + forwardRef: true, lazy: true, classComponents: true, hydration: true, + }, +} + +function resolveFeatures( + preset: RedactPreset, + overrides: RedactFeatures, +): ResolvedFeatures { + const p = PRESET_DEFAULTS[preset] + return { + portal: overrides.portal ?? p.portal, + context: overrides.context ?? p.context, + suspense: overrides.suspense ?? p.suspense, + memo: overrides.memo ?? p.memo, + forwardRef: overrides.forwardRef ?? p.forwardRef, + lazy: overrides.lazy ?? p.lazy, + classComponents: overrides.classComponents ?? p.classComponents, + hydration: overrides.hydration ?? p.hydration, + } +} + +export interface RedactOptions { + /** Skip aliasing specific specifiers, e.g. if a consumer wants real React somewhere. */ + skip?: ReadonlyArray + /** + * Override package resolution root. Defaults to the Vite config root. Useful + * for monorepos where the plugin lives in a different workspace than the + * consumer app. + */ + resolveFrom?: string + /** + * Explicit package roots, bypassing node_modules lookup. Keys are package + * names (e.g. `@tanstack/redact`), values are absolute paths to the package + * directory. Handy for cross-workspace testing / bring-your-own-build setups. + */ + packageRoots?: Record + /** + * Starting point for feature selection. `'full'` (default) turns every + * feature on — drop-in React parity, opt-out individual features via + * `features`. `'nano'` turns everything off — opt in to what you need. + */ + preset?: RedactPreset + /** + * Per-feature overrides merged on top of the preset's defaults. Enables + * fine-grained "preset minus X" or "preset plus Y" configurations. + */ + features?: RedactFeatures +} + +// Alias map. ORDER MATTERS — Vite's alias matcher uses first-match against +// prefix, so more-specific specifiers MUST come before less-specific ones. +// Without this, `react-dom/server` would prefix-match `react-dom` first and +// resolve to `@tanstack/redact/dom/server` (wrong) instead of +// `@tanstack/redact/server`. +// +// `use-sync-external-store` aliases are here because its CJS-only React 17 +// compat shim does `var React = require('react')`. That survives Vite's +// pre-bundling intact and explodes in Cloudflare Workers (no `require`). +// Modern React has `useSyncExternalStore` built-in, and `@tanstack/redact` +// additionally exports `useSyncExternalStoreWithSelector` so this alias +// is safe everywhere. +const ALIASES: Record = { + // ---- most-specific first ---- + 'use-sync-external-store/shim/with-selector': '@tanstack/redact', + 'use-sync-external-store/shim/with-selector.js': '@tanstack/redact', + 'use-sync-external-store/with-selector': '@tanstack/redact', + 'use-sync-external-store/with-selector.js': '@tanstack/redact', + 'use-sync-external-store/shim': '@tanstack/redact', + 'use-sync-external-store': '@tanstack/redact', + + // React drop-in shim targets. Subpaths first. + 'react/jsx-runtime': '@tanstack/redact/jsx-runtime', + 'react/jsx-dev-runtime': '@tanstack/redact/jsx-dev-runtime', + 'react-dom/client': '@tanstack/redact/dom-client', + 'react-dom/server': '@tanstack/redact/server', + 'react-dom/test-utils': '@tanstack/redact/dom-test-utils', + 'react-dom': '@tanstack/redact/dom', + react: '@tanstack/redact', + scheduler: '@tanstack/redact/scheduler', + + // Self-aliases so Vite resolves `@tanstack/redact/*` imports to the same + // canonical file path no matter where they originate (worker bundle vs + // deps_ssr pre-bundle vs source). Without these, Cloudflare's + // `noExternal: true` worker config inlines one copy while Vite's + // optimizeDeps pre-bundles another, ending up with two separate + // ReactSharedInternals instances and a null dispatcher in user hooks. + // Subpaths first here too. + '@tanstack/redact/jsx-runtime': '@tanstack/redact/jsx-runtime', + '@tanstack/redact/jsx-dev-runtime': '@tanstack/redact/jsx-dev-runtime', + '@tanstack/redact/dom-client': '@tanstack/redact/dom-client', + '@tanstack/redact/dom-test-utils': '@tanstack/redact/dom-test-utils', + '@tanstack/redact/server': '@tanstack/redact/server', + '@tanstack/redact/scheduler': '@tanstack/redact/scheduler', + '@tanstack/redact/dom': '@tanstack/redact/dom', + '@tanstack/redact': '@tanstack/redact', +} + +function splitSpecifier(specifier: string): { pkg: string; sub: string } { + if (specifier.startsWith('@')) { + const slash1 = specifier.indexOf('/') + const slash2 = specifier.indexOf('/', slash1 + 1) + if (slash2 < 0) return { pkg: specifier, sub: '' } + return { pkg: specifier.slice(0, slash2), sub: specifier.slice(slash2 + 1) } + } + const slash = specifier.indexOf('/') + if (slash < 0) return { pkg: specifier, sub: '' } + return { pkg: specifier.slice(0, slash), sub: specifier.slice(slash + 1) } +} + +function findPackageDir(pkg: string, fromDir: string): string | null { + let dir = fromDir + while (true) { + const candidate = resolvePath(dir, 'node_modules', pkg) + if (existsSync(resolvePath(candidate, 'package.json'))) return candidate + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +function resolveExport(packageDir: string, sub: string): string | null { + const pkgJsonPath = resolvePath(packageDir, 'package.json') + let pkg: any + try { + pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) + } catch { + return null + } + const key = sub ? './' + sub : '.' + const exp = pkg.exports?.[key] + // Prefer published `import` (dist/.js) over `source` — dist is a single + // transformed bundle so Vite's dep optimizer doesn't thrash on dozens of + // individual source files. The package keeps cross-subpath imports + // external, so there's still only one runtime instance. + const pick = (v: any): string | null => { + if (typeof v === 'string') return v + if (v && typeof v === 'object') { + return pick(v.import ?? v.module ?? v.source ?? v.default ?? null) + } + return null + } + const target = pick(exp) + if (target) return resolvePath(packageDir, target) + if (!sub) { + const main = pkg.module ?? pkg.main + if (typeof main === 'string') return resolvePath(packageDir, main) + } + return null +} + +// When installed from npm, `@tanstack/redact` is declared as a `dependency` +// of consumer apps. Under pnpm's strict mode it ends up nested under the +// plugin's own `.pnpm/@tanstack+redact@.../node_modules/` rather than +// hoisted to the consumer's root, so a `findPackageDir` walk starting at +// the Vite project root won't always find it. Search from the plugin's own +// directory first (which walks into its nested node_modules), then fall +// back to the consumer root for hoisted installs. +const pluginDir = dirname(fileURLToPath(import.meta.url)) + +function resolveSpecifier( + specifier: string, + fromDir: string, + packageRoots: Record, +): string | null { + const { pkg, sub } = splitSpecifier(specifier) + const packageDir = + packageRoots[pkg] ?? + findPackageDir(pkg, pluginDir) ?? + findPackageDir(pkg, fromDir) + if (!packageDir) return null + const target = resolveExport(packageDir, sub) + if (!target) return null + // Canonicalize through pnpm symlinks. Under strict pnpm, the package may + // live nested under `.pnpm/@tanstack+redact@.../node_modules/*`, but each + // of those is itself a symlink to the flat `.pnpm/@tanstack+redact@.../` + // entry. Vite's `fetchModule` (used by TanStack Start's server-fn + // compiler) follows the realpath, so the id seen by the capture-transform + // differs from the nested id we'd return. That leaves the compiler's + // moduleCache keyed on the realpath while `getModuleInfo` looks up the + // nested path → miss → "could not load module info". Returning the + // canonical realpath here keeps the two sides in agreement. + try { + return realpathSync(target) + } catch { + return target + } +} + +export function redact(options: RedactOptions = {}): any { + const skip = new Set(options.skip ?? []) + const entries = Object.entries(ALIASES).filter(([k]) => !skip.has(k)) + const features = resolveFeatures(options.preset ?? 'full', options.features ?? {}) + + const resolvedMap: Record = {} + let done = false + + function resolveAll(root: string): void { + if (done) return + const fromDir = options.resolveFrom ?? root + const packageRoots = options.packageRoots ?? {} + for (const [from, to] of entries) { + const resolved = resolveSpecifier(to, fromDir, packageRoots) + if (resolved) resolvedMap[from] = resolved + } + done = true + } + + return { + name: 'redact', + enforce: 'pre', + + config() { + const excludeList = entries.map(([k]) => k) + // Single package — only one name to dedupe / no-external. + const noExt = ['@tanstack/redact'] + // Top-level resolve.alias so `react` → `@tanstack/redact` happens + // BEFORE any plugin-based resolveId hook fires. The Cloudflare + // vite-plugin's rolldown worker-runner pre-scans the worker entry's + // exports and resolves bare specifiers via Vite's alias map directly + // (not via plugin hooks), so without this it would resolve `react` to + // the real npm package and try to `require()` it in a Worker (no CJS + // support). Object form is required — array form is silently ignored + // by rolldown's worker-runner. + const aliasMap = Object.fromEntries(entries.filter(([from, to]) => from !== to)) + // Scope optimizeDeps to client + ssr environments ONLY. Do NOT set a + // top-level optimizeDeps — in Vite 6+ that's effectively the client + // env's default but also seeps into the rsc env's `'use client'` + // analysis, causing flood warnings like "inconsistently optimized". + // Dedupe `@tanstack/redact` so Vite resolves it to a single instance + // even when multiple packages (e.g. @tanstack/react-router and user + // code) drag it into different parts of the module graph. + const dedupe = noExt + return { + resolve: { + alias: aliasMap, + dedupe, + }, + environments: { + client: { + optimizeDeps: { exclude: excludeList }, + }, + ssr: { + optimizeDeps: { exclude: excludeList }, + resolve: { noExternal: noExt }, + }, + }, + ssr: { noExternal: noExt }, + } + }, + + configResolved(config: any) { + resolveAll(config.root) + // With `packageRoots`, package sources live outside the consumer's Vite + // project root, so the default server.fs.allow list blocks them. Append + // to the resolved allow list rather than replacing via `config()`, so we + // keep Vite's defaults (root + node_modules + client runtime). + const fsAllow = Object.values(options.packageRoots ?? {}) + if (fsAllow.length && config.server?.fs?.allow) { + for (const p of fsAllow) { + if (!config.server.fs.allow.includes(p)) { + config.server.fs.allow.push(p) + } + } + } + }, + + async resolveId(this: any, id: string, importer?: string, opts?: any) { + // Skip the RSC environment — it relies on real React internals via + // @vitejs/plugin-rsc's vendored react-server-dom. Substituting our + // shim there breaks Flight serialization. Client + SSR envs still swap. + const envName = this?.environment?.name + if (envName === 'rsc') return null + + // Feature-flag swap: when the reconciler's `features/index` module + // imports a feature by relative path, redirect to that feature's stub + // if the flag is off. The stub registers a graceful-degradation + // matcher (e.g. Portal → Fragment) so user code keeps working. + if (importer && /[\\/]features[\\/]index\.[jt]sx?$/.test(importer)) { + const m = id.match(/^\.\/([a-z-]+)$/) + if (m) { + const name = m[1] as keyof ResolvedFeatures + if (name in features && !features[name]) { + const r = await this.resolve(`./${name}/stub`, importer, { + ...opts, + skipSelf: true, + }) + if (r) return r.id + } + } + } + + // Hydration swap: hydration isn't self-registering, so it's imported + // from reconcile.ts, root.ts, and the Suspense/Lazy feature modules. + // Any specifier ending in `/hydration` that resolves to our feature + // module gets redirected to the stub when the flag is off. + if (!features.hydration && importer && /[\\/]hydration$/.test(id)) { + const r = await this.resolve(id, importer, { ...opts, skipSelf: true }) + if (r && /features[\\/]hydration[\\/]index\.(ts|js)$/.test(r.id)) { + return r.id.replace(/index\.(ts|js)$/, 'stub.$1') + } + } + + return resolvedMap[id] ?? null + }, + } +} + +export default redact diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json deleted file mode 100644 index e56ce59..0000000 --- a/packages/scheduler/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@tanstack/scheduler", - "version": "0.1.0-alpha.8", - "description": "Drop-in 'scheduler' replacement — microtask-based, no time slicing", - "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.js", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "src" - ], - "sideEffects": false, - "scripts": { - "build": "echo TODO", - "dev": "echo TODO", - "size": "echo TODO" - }, - "publishConfig": { - "access": "public", - "tag": "next" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33e889..60abb6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,18 +25,9 @@ importers: examples/ssr-demo: dependencies: - '@tanstack/dom-vite': + '@tanstack/redact': specifier: workspace:* - version: link:../../packages/dom-vite - '@tanstack/react': - specifier: workspace:* - version: link:../../packages/react - '@tanstack/react-dom': - specifier: workspace:* - version: link:../../packages/react-dom - '@tanstack/react-dom-server': - specifier: workspace:* - version: link:../../packages/react-dom-server + version: link:../../packages/redact devDependencies: '@playwright/test': specifier: ^1.59.1 @@ -54,86 +45,17 @@ importers: specifier: ^5.4.11 version: 5.4.21(@types/node@22.19.17)(lightningcss@1.30.2) - packages/core: {} - - packages/dom-vite: + packages/redact: dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../core - '@tanstack/react': - specifier: workspace:* - version: link:../react - '@tanstack/react-dom': - specifier: workspace:* - version: link:../react-dom - '@tanstack/react-dom-server': - specifier: workspace:* - version: link:../react-dom-server - '@tanstack/scheduler': - specifier: workspace:* - version: link:../scheduler vite: specifier: '>=5' version: 5.4.21(@types/node@25.6.0)(lightningcss@1.30.2) - packages/hydration-runtime: {} - - packages/jsx-runtime: - dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../core - - packages/react: - dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../core - - packages/react-dom: - dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../core - '@tanstack/react': - specifier: workspace:* - version: link:../react - - packages/react-dom-server: - dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../core - '@tanstack/react': - specifier: workspace:* - version: link:../react - - packages/scheduler: {} - tests: dependencies: - '@tanstack/dom-core': - specifier: workspace:* - version: link:../packages/core - '@tanstack/dom-hydration-runtime': - specifier: workspace:* - version: link:../packages/hydration-runtime - '@tanstack/jsx-runtime': - specifier: workspace:* - version: link:../packages/jsx-runtime - '@tanstack/react': - specifier: workspace:* - version: link:../packages/react - '@tanstack/react-dom': - specifier: workspace:* - version: link:../packages/react-dom - '@tanstack/react-dom-server': - specifier: workspace:* - version: link:../packages/react-dom-server - '@tanstack/scheduler': + '@tanstack/redact': specifier: workspace:* - version: link:../packages/scheduler + version: link:../packages/redact devDependencies: '@vitest/browser': specifier: ^2.1.8 diff --git a/scripts/build.mjs b/scripts/build.mjs index 09297d0..3025529 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,123 +1,89 @@ #!/usr/bin/env node -// Build every @tanstack/* package to dist/ as ESM + .d.ts. -// Consumers install these as normal npm packages. +// Build the @tanstack/redact package to dist/. +// +// The package is laid out as one tree with internal subdirectories +// (core, react, dom, server, scheduler, vite). Every TS module is emitted +// as its own dist file, with relative imports preserved literally — that +// keeps a single runtime instance of every module no matter which subpath +// the consumer imports first, and preserves the import-graph boundaries +// the `redact()` Vite plugin needs at consumer-build time to swap feature +// `index.js` modules for their `stub.js` counterparts. import { build } from 'esbuild' import { execSync } from 'node:child_process' -import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' -import { resolve, dirname } from 'node:path' +import { + cpSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + existsSync, + readdirSync, + statSync, +} from 'node:fs' +import { resolve, dirname, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, '..') +const pkgDir = resolve(root, 'packages/redact') +const srcDir = resolve(pkgDir, 'src') +const distDir = resolve(pkgDir, 'dist') -// No aliases during build — rely on `external` to keep cross-package imports -// as bare specifiers so consumers resolve them through their own package -// resolution, ensuring ONE runtime instance of each @tanstack/* module. +function listTsFiles(dir) { + const out = [] + for (const name of readdirSync(dir)) { + const full = resolve(dir, name) + const st = statSync(full) + if (st.isDirectory()) { + out.push(...listTsFiles(full)) + } else if (full.endsWith('.ts') && !full.endsWith('.d.ts')) { + out.push(full) + } + } + return out +} -const packages = [ - { - name: '@tanstack/dom-core', - dir: 'packages/core', - entries: { 'index.js': 'src/index.ts' }, - }, - { - name: '@tanstack/scheduler', - dir: 'packages/scheduler', - entries: { 'index.js': 'src/index.ts' }, - }, - { - name: '@tanstack/react', - dir: 'packages/react', - entries: { - 'index.js': 'src/index.ts', - 'jsx-runtime.js': 'src/jsx-runtime.ts', - }, - }, - { - name: '@tanstack/react-dom', - dir: 'packages/react-dom', - // Build the internal union first, then thin re-export shims that import - // from it. Keeps a single copy of reconciler state across all entries. - entries: { - '_all.js': 'src/_all.ts', - }, - postBuild: ({ distDir }) => { - // Emit tiny facades that re-export from _all.js - writeFileSync( - resolve(distDir, 'index.js'), - `export { flushSync, unstable_batchedUpdates, createPortal, preconnect, prefetchDNS, preload, preinit, preloadModule, preinitModule, version } from './_all.js' -export * as default from './_all.js' -`, - ) - writeFileSync( - resolve(distDir, 'client.js'), - `export { createRoot, hydrateRoot } from './_all.js'\n`, - ) - writeFileSync( - resolve(distDir, 'test-utils.js'), - `export { act } from './_all.js'\n`, - ) - }, - }, - { - name: '@tanstack/react-dom-server', - dir: 'packages/react-dom-server', - entries: { 'index.js': 'src/index.ts' }, +// Each TS module emits its own dist file with all relative imports kept as +// literal external imports in the output. This preserves boundaries the +// `redact()` plugin needs to swap features//index.js → stub.js at +// consumer-build time, and keeps single-instance state because each module +// is emitted exactly once in dist/. +const externalizeRelative = { + name: 'externalize-relative', + setup(b) { + b.onResolve({ filter: /^\.\.?\// }, (args) => { + if (args.kind === 'entry-point') return null + return { external: true, path: args.path } + }) }, - { - name: '@tanstack/dom-vite', - dir: 'packages/dom-vite', - entries: { 'index.js': 'src/index.ts' }, - external: ['vite', 'node:*'], - platform: 'node', - }, -] +} -async function buildPackage(pkg) { - const pkgDir = resolve(root, pkg.dir) - const distDir = resolve(pkgDir, 'dist') +async function buildPackage() { rmSync(distDir, { recursive: true, force: true }) mkdirSync(distDir, { recursive: true }) - // Keep cross-@tanstack imports EXTERNAL so consumers end up with a single - // copy of each package. If we inlined (`bundle: true` without externals), - // @tanstack/react-dom's dist would include its own ReactSharedInternals and - // hooks would break with "mismatching versions of React" when user code - // also imports @tanstack/react directly. - const externals = [ - '@tanstack/react', - '@tanstack/react/jsx-runtime', - '@tanstack/react-dom', - '@tanstack/react-dom/client', - '@tanstack/react-dom/test-utils', - '@tanstack/react-dom-server', - '@tanstack/dom-core', - '@tanstack/scheduler', - ...(pkg.external ?? []), - ] - // Build all entries of a package together with splitting enabled so shared - // modules (e.g. react-dom's hydration.ts imported by both index and client) - // end up in one chunk — otherwise each entry gets its own copy and - // module-level state (CLAIMED WeakSet, caches) ends up duplicated at - // runtime, breaking cross-entry coordination. - const entryPoints = Object.entries(pkg.entries).map(([out, src]) => ({ - in: resolve(pkgDir, src), - out: out.replace(/\.js$/, ''), - })) - await build({ - entryPoints, - bundle: true, - format: 'esm', - platform: pkg.platform ?? 'browser', - target: 'es2022', - outdir: distDir, - external: externals, - sourcemap: true, - logLevel: 'warning', - }) + const tsFiles = listTsFiles(srcDir) + console.log(`Building @tanstack/redact: ${tsFiles.length} entries...\n`) - if (pkg.postBuild) { - pkg.postBuild({ distDir }) + for (const tsFile of tsFiles) { + const relPath = relative(srcDir, tsFile).replace(/\.ts$/, '') + // The vite plugin runs in Node — uses fs/path/url. Browser/SSR code + // never imports from `vite/`, so the `node:` builtins it uses are + // safe to leave external in that one entry. + const isVite = relPath.startsWith('vite' + sep) || relPath === 'vite' + await build({ + entryPoints: [{ in: tsFile, out: relPath }], + bundle: true, + format: 'esm', + platform: isVite ? 'node' : 'browser', + target: 'es2022', + outdir: distDir, + external: isVite ? ['vite', 'node:*'] : [], + sourcemap: true, + splitting: false, + plugins: [externalizeRelative], + logLevel: 'warning', + }) } // Generate .d.ts files. tsc --emitDeclarationOnly. @@ -134,14 +100,7 @@ async function buildPackage(pkg) { outDir: './dist', rootDir: './src', jsx: 'react-jsx', - jsxImportSource: '@tanstack/react', - paths: { - '@tanstack/dom-core': [resolve(root, 'packages/core/src/index.ts')], - '@tanstack/react': [resolve(root, 'packages/react/src/index.ts')], - '@tanstack/react/jsx-runtime': [resolve(root, 'packages/react/src/jsx-runtime.ts')], - '@tanstack/react-dom': [resolve(root, 'packages/react-dom/src/index.ts')], - '@tanstack/react-dom/client': [resolve(root, 'packages/react-dom/src/client.ts')], - }, + jsxImportSource: '@tanstack/redact', }, include: [resolve(pkgDir, 'src/**/*')], } @@ -155,11 +114,8 @@ async function buildPackage(pkg) { rmSync(tsconfigPath, { force: true }) } - console.log(` ✓ ${pkg.name}`) + console.log(`\n ✓ @tanstack/redact`) } -console.log('Building packages...\n') -for (const pkg of packages) { - await buildPackage(pkg) -} +await buildPackage() console.log('\nDone.') diff --git a/scripts/size-analyze.mjs b/scripts/size-analyze.mjs new file mode 100644 index 0000000..03dd39b --- /dev/null +++ b/scripts/size-analyze.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// One-off analysis: produce a per-module byte breakdown of the nano bundle. +import { build, analyzeMetafile } from 'esbuild' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const alias = { + '@tanstack/dom-core': resolve(root, 'packages/core/src/index.ts'), + '@tanstack/react/jsx-runtime': resolve(root, 'packages/react/src/jsx-runtime.ts'), + '@tanstack/react': resolve(root, 'packages/react/src/index.ts'), + '@tanstack/react-dom/client': resolve(root, 'packages/react-dom/src/client.ts'), + '@tanstack/react-dom': resolve(root, 'packages/react-dom/src/index.ts'), +} + +const FEATURE_DIR_MAP = { + portal: 'portal', context: 'context', suspense: 'suspense', memo: 'memo', + forwardRef: 'forward-ref', lazy: 'lazy', classComponents: 'class', +} + +function featureSwapPlugin(features) { + const dirToKey = Object.fromEntries( + Object.entries(FEATURE_DIR_MAP).map(([key, dir]) => [dir, key]), + ) + return { + name: 'feature-swap', + setup(b) { + b.onResolve({ filter: /^\.\/[a-z-]+$/ }, (args) => { + if (!args.importer) return null + if (!/features[\\/]index\.[jt]sx?$/.test(args.importer)) return null + const m = args.path.match(/^\.\/([a-z-]+)$/) + if (!m) return null + const dir = m[1] + const key = dirToKey[dir] + if (key && features[key] === false) { + return { path: resolve(dirname(args.importer), dir, 'stub.ts') } + } + return null + }) + }, + } +} + +const allOff = { + portal: false, context: false, suspense: false, memo: false, + forwardRef: false, lazy: false, classComponents: false, +} + +const result = await build({ + entryPoints: [resolve(root, 'packages/react-dom/src/client.ts')], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + minify: true, + treeShaking: true, + write: false, + alias, + plugins: [featureSwapPlugin(allOff)], + define: { 'process.env.NODE_ENV': '"production"' }, + metafile: true, + logLevel: 'warning', +}) + +// Analyze uncompressed (minified) contributions per input file. Gzipped +// numbers are roughly proportional but not directly attributable per-file. +const inputs = result.metafile.outputs[Object.keys(result.metafile.outputs)[0]].inputs +const rows = Object.entries(inputs).map(([path, info]) => ({ + path: path.replace(root + '/', '').replace('packages/', ''), + bytes: info.bytesInOutput, +})) +rows.sort((a, b) => b.bytes - a.bytes) + +const total = rows.reduce((s, r) => s + r.bytes, 0) +console.log(`\nNano bundle — minified byte contribution per module (total ${total} B min):\n`) +const w = Math.max(...rows.map((r) => r.path.length)) +for (const r of rows) { + const pct = ((r.bytes / total) * 100).toFixed(1).padStart(5) + console.log(` ${r.path.padEnd(w)} ${String(r.bytes).padStart(6)} B ${pct}%`) +} +console.log() diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs new file mode 100644 index 0000000..b0174c7 --- /dev/null +++ b/scripts/size-check.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +// Size budgets. Fails CI if any entry exceeds its gzip budget. Regenerate the +// baseline by running `node scripts/size.mjs`, copying the gzip column, and +// bumping the budget intentionally — size regressions should require a +// conscious choice, not slip through. +import { build } from 'esbuild' +import { gzipSync } from 'node:zlib' +import { writeFileSync, mkdirSync, rmSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = resolve(__dirname, '..') + +// gzip-byte budgets per named configuration. Update intentionally — size +// regressions should require a conscious choice, not slip through CI. +// Current sizes (Apr 2026): 2447 / 9241 / 8078 / 8581. Budgets include a +// small cushion over current (~60 B) to absorb minor noise. Shrink budgets +// when you intentionally make the bundle smaller. +const BUDGETS = { + 'react': 2500, + 'react-dom/client': 9300, + 'react-dom/client (nano)': 6950, + 'react-dom/client (suspense=stub)': 8650, + 'react-dom/client (hydration=stub)': 8000, +} + +const alias = { + '@tanstack/dom-core': resolve(root, 'packages/core/src/index.ts'), + '@tanstack/react/jsx-runtime': resolve(root, 'packages/react/src/jsx-runtime.ts'), + '@tanstack/react': resolve(root, 'packages/react/src/index.ts'), + '@tanstack/react-dom/client': resolve(root, 'packages/react-dom/src/client.ts'), + '@tanstack/react-dom': resolve(root, 'packages/react-dom/src/index.ts'), +} + +const FEATURE_DIR_MAP = { + portal: 'portal', + context: 'context', + suspense: 'suspense', + memo: 'memo', + forwardRef: 'forward-ref', + lazy: 'lazy', + classComponents: 'class', + hydration: 'hydration', +} + +function featureSwapPlugin(features) { + const dirToKey = Object.fromEntries( + Object.entries(FEATURE_DIR_MAP).map(([key, dir]) => [dir, key]), + ) + return { + name: 'feature-swap', + setup(b) { + b.onResolve({ filter: /^\.\/[a-z-]+$/ }, (args) => { + if (!args.importer) return null + if (!/features[\\/]index\.[jt]sx?$/.test(args.importer)) return null + const m = args.path.match(/^\.\/([a-z-]+)$/) + if (!m) return null + const dir = m[1] + const key = dirToKey[dir] + if (key && features[key] === false) { + return { path: resolve(dirname(args.importer), dir, 'stub.ts') } + } + return null + }) + if (features.hydration === false) { + b.onResolve({ filter: /[/\\]hydration$/ }, (args) => { + const m = args.path.match(/^((?:\.\.?[/\\])+)(?:features[/\\])?hydration$/) + if (!m || !args.importer) return null + const wantsFeaturesPrefix = /features[/\\]/.test(args.path) + const tail = wantsFeaturesPrefix ? 'features/hydration/stub.ts' : 'hydration/stub.ts' + const candidate = resolve(dirname(args.importer), m[1], tail) + if (/[/\\]features[/\\]hydration[/\\]stub\.ts$/.test(candidate)) { + return { path: candidate } + } + return null + }) + } + }, + } +} + +const outDir = resolve(root, '.size-check') +rmSync(outDir, { recursive: true, force: true }) +mkdirSync(outDir, { recursive: true }) + +const entries = [ + { name: 'react', path: 'packages/react/src/index.ts' }, + { name: 'react-dom/client', path: 'packages/react-dom/src/client.ts' }, + { + name: 'react-dom/client (nano)', + path: 'packages/react-dom/src/client.ts', + features: { + portal: false, context: false, suspense: false, memo: false, + forwardRef: false, lazy: false, classComponents: false, hydration: false, + }, + }, + { + name: 'react-dom/client (suspense=stub)', + path: 'packages/react-dom/src/client.ts', + features: { suspense: false }, + }, + { + name: 'react-dom/client (hydration=stub)', + path: 'packages/react-dom/src/client.ts', + features: { hydration: false }, + }, +] + +let failed = false +for (const entry of entries) { + const result = await build({ + entryPoints: [resolve(root, entry.path)], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + minify: true, + treeShaking: true, + write: false, + alias, + plugins: entry.features ? [featureSwapPlugin(entry.features)] : [], + define: { 'process.env.NODE_ENV': '"production"' }, + logLevel: 'warning', + }) + const gz = gzipSync(result.outputFiles[0].contents).length + const budget = BUDGETS[entry.name] + if (budget == null) { + console.log(` ? ${entry.name}: ${gz} B gzip (no budget)`) + continue + } + const over = gz - budget + if (over > 0) { + console.log(` ✗ ${entry.name}: ${gz} B gzip (over budget by ${over} B, budget ${budget})`) + failed = true + } else { + console.log(` ✓ ${entry.name}: ${gz} B gzip (${-over} B under budget ${budget})`) + } +} + +rmSync(outDir, { recursive: true, force: true }) + +if (failed) { + console.log('\nSize budget exceeded. Regenerate baselines in BUDGETS if the growth is intentional.\n') + process.exit(1) +} +console.log('\nAll size budgets satisfied.\n') diff --git a/scripts/size.mjs b/scripts/size.mjs index a061b0b..e301ff0 100644 --- a/scripts/size.mjs +++ b/scripts/size.mjs @@ -10,34 +10,160 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const root = resolve(__dirname, '..') const entries = [ - { name: 'react', path: 'packages/react/src/index.ts' }, - { name: 'react/jsx-runtime', path: 'packages/react/src/jsx-runtime.ts' }, - { name: 'react-dom', path: 'packages/react-dom/src/index.ts' }, - { name: 'react-dom/client', path: 'packages/react-dom/src/client.ts' }, - { name: 'react-dom/server', path: 'packages/react-dom-server/src/index.ts' }, + { name: 'redact', path: 'packages/redact/src/react/index.ts' }, + { name: 'redact/jsx-runtime', path: 'packages/redact/src/react/jsx-runtime.ts' }, + { name: 'redact/dom', path: 'packages/redact/src/dom/index.ts' }, + { name: 'redact/dom-client', path: 'packages/redact/src/dom/client.ts' }, + { name: 'redact/server', path: 'packages/redact/src/server/index.ts' }, { - name: 'client total (react + react-dom/client + jsx-runtime)', + name: 'client total (redact + redact/dom-client + jsx-runtime)', virtual: true, bundleCode: ` - export * from '@tanstack/react' - export * from '@tanstack/react/jsx-runtime' - export * from '@tanstack/react-dom' - export * from '@tanstack/react-dom/client' + export * from '@tanstack/redact' + export * from '@tanstack/redact/jsx-runtime' + export * from '@tanstack/redact/dom' + export * from '@tanstack/redact/dom-client' `, }, + // Demo: simulate what the vite plugin does when features are flagged off. + // Each entry reuses an upstream entry but swaps the listed features to + // their stub modules, showing the byte savings the plugin delivers. + { + name: 'redact/dom-client (portal=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { portal: false }, + }, + { + name: 'redact/dom-client (context=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { context: false }, + }, + { + name: 'redact/dom-client (suspense=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { suspense: false }, + }, + { + name: 'redact/dom-client (memo=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { memo: false }, + }, + { + name: 'redact/dom-client (forwardRef=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { forwardRef: false }, + }, + { + name: 'redact/dom-client (lazy=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { lazy: false }, + }, + { + name: 'redact/dom-client (classComponents=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { classComponents: false }, + }, + { + name: 'redact/dom-client (hydration=stub)', + path: 'packages/redact/src/dom/client.ts', + features: { hydration: false }, + }, + { + name: 'redact/dom-client (nano preset)', + path: 'packages/redact/src/dom/client.ts', + features: { + portal: false, + context: false, + suspense: false, + memo: false, + forwardRef: false, + lazy: false, + classComponents: false, + hydration: false, + }, + }, ] +// esbuild plugin: redirect `./` imports in react-dom's features/index +// to the corresponding stub module. Mirrors the resolveId hook in +// @tanstack/dom-vite so size numbers reflect real plugin output. +// Feature names use kebab-case on disk (features/forward-ref/, features/class/) +// but camelCase in the config (`forwardRef: true`, `classComponents: true`). +const FEATURE_DIR_MAP = { + portal: 'portal', + context: 'context', + suspense: 'suspense', + memo: 'memo', + forwardRef: 'forward-ref', + lazy: 'lazy', + classComponents: 'class', + hydration: 'hydration', +} + +function featureSwapPlugin(features) { + const dirToKey = Object.fromEntries( + Object.entries(FEATURE_DIR_MAP).map(([key, dir]) => [dir, key]), + ) + return { + name: 'feature-swap', + setup(build) { + // Features imported by features/index.ts (the self-registering ones). + build.onResolve({ filter: /^\.\/[a-z-]+$/ }, (args) => { + if (!args.importer) return null + if (!/features[\\/]index\.[jt]sx?$/.test(args.importer)) return null + const m = args.path.match(/^\.\/([a-z-]+)$/) + if (!m) return null + const dir = m[1] + const key = dirToKey[dir] + if (key && features[key] === false) { + return { path: resolve(dirname(args.importer), dir, 'stub.ts') } + } + return null + }) + // Hydration: imported from reconcile/root/suspense/lazy. Match any + // specifier ending in `/hydration` that resolves to our feature. + if (features.hydration === false) { + build.onResolve({ filter: /[/\\]hydration$/ }, (args) => { + // Resolve manually: walk up from importer's dir based on the prefix + // in args.path. Only redirects if the resolved path lands in our + // features/hydration/ directory. + const m = args.path.match(/^((?:\.\.?[/\\])+)(?:features[/\\])?hydration$/) + if (!m || !args.importer) return null + const stub = resolve( + dirname(args.importer), + m[1], + args.path.includes('features/') || args.path.includes('features\\') + ? 'features/hydration/stub.ts' + : 'hydration/stub.ts', + ) + // Match either import style: /features/hydration OR /hydration + // relative to the /features/... path. + if (stub.endsWith('/features/hydration/stub.ts') || stub.endsWith('\\features\\hydration\\stub.ts')) { + return { path: stub } + } + // Fall-through: the sibling `../hydration` form from features/*/full.ts + // resolves into features/hydration/stub.ts via dir walk. + const sibling = resolve(dirname(args.importer), m[1], 'hydration/stub.ts') + if (sibling.includes('/features/hydration/') || sibling.includes('\\features\\hydration\\')) { + return { path: sibling } + } + return null + }) + } + }, + } +} + const outDir = resolve(root, '.size') rmSync(outDir, { recursive: true, force: true }) mkdirSync(outDir, { recursive: true }) const alias = { - '@tanstack/dom-core': resolve(root, 'packages/core/src/index.ts'), - '@tanstack/react/jsx-runtime': resolve(root, 'packages/react/src/jsx-runtime.ts'), - '@tanstack/react': resolve(root, 'packages/react/src/index.ts'), - '@tanstack/react-dom/client': resolve(root, 'packages/react-dom/src/client.ts'), - '@tanstack/react-dom': resolve(root, 'packages/react-dom/src/index.ts'), - '@tanstack/react-dom-server': resolve(root, 'packages/react-dom-server/src/index.ts'), + '@tanstack/redact/jsx-runtime': resolve(root, 'packages/redact/src/react/jsx-runtime.ts'), + '@tanstack/redact/dom-client': resolve(root, 'packages/redact/src/dom/client.ts'), + '@tanstack/redact/dom': resolve(root, 'packages/redact/src/dom/index.ts'), + '@tanstack/redact/server': resolve(root, 'packages/redact/src/server/index.ts'), + '@tanstack/redact': resolve(root, 'packages/redact/src/react/index.ts'), } const rows = [] @@ -62,6 +188,7 @@ for (const entry of entries) { treeShaking: true, write: false, alias, + plugins: entry.features ? [featureSwapPlugin(entry.features)] : [], // Consumers build with NODE_ENV=production; mirror here so `if (process.env.NODE_ENV !== 'production')` // dev-only branches get DCE'd the same way in our size numbers as in shipped bundles. define: { 'process.env.NODE_ENV': '"production"' }, diff --git a/tests/feature-stubs.test.tsx b/tests/feature-stubs.test.tsx new file mode 100644 index 0000000..ee8b94a --- /dev/null +++ b/tests/feature-stubs.test.tsx @@ -0,0 +1,50 @@ +/** + * Stub smoke test: each feature's stub module must load, self-register, and + * not crash. This catches broken imports, stale API references, and missing + * exports on the stub side. Full behavior tests live in feature-specific + * test files. + */ +import { describe, it, expect } from 'vitest' + +describe('feature stubs — load & register', () => { + it('portal/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/portal/stub.ts'), + ).resolves.toBeTruthy() + }) + it('context/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/context/stub.ts'), + ).resolves.toBeTruthy() + }) + it('suspense/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/suspense/stub.ts'), + ).resolves.toBeTruthy() + }) + it('memo/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/memo/stub.ts'), + ).resolves.toBeTruthy() + }) + it('forward-ref/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/forward-ref/stub.ts'), + ).resolves.toBeTruthy() + }) + it('lazy/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/lazy/stub.ts'), + ).resolves.toBeTruthy() + }) + it('class/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/class/stub.ts'), + ).resolves.toBeTruthy() + }) + it('hydration/stub loads', async () => { + await expect( + import('../packages/redact/src/dom/features/hydration/stub.ts'), + ).resolves.toBeTruthy() + }) +}) diff --git a/tests/package.json b/tests/package.json index b8c944f..41d5796 100644 --- a/tests/package.json +++ b/tests/package.json @@ -8,13 +8,7 @@ "test:watch": "vitest" }, "dependencies": { - "@tanstack/dom-core": "workspace:*", - "@tanstack/react": "workspace:*", - "@tanstack/react-dom": "workspace:*", - "@tanstack/react-dom-server": "workspace:*", - "@tanstack/jsx-runtime": "workspace:*", - "@tanstack/scheduler": "workspace:*", - "@tanstack/dom-hydration-runtime": "workspace:*" + "@tanstack/redact": "workspace:*" }, "devDependencies": { "vitest": "^2.1.8", diff --git a/tests/public-exports.test.ts b/tests/public-exports.test.ts new file mode 100644 index 0000000..78e141a --- /dev/null +++ b/tests/public-exports.test.ts @@ -0,0 +1,167 @@ +/** + * Public API surface lock-in. + * + * Why this test exists: it catches the class of bug where you add a function + * to a sub-module (e.g. `react/hooks.ts`) but forget to add it to the + * subpath's top-level `index.ts` re-export block. The function exists, types + * check, the build succeeds — but consumers importing the package by name + * get a silent `SyntaxError: does not provide an export named 'X'` at + * module-link time, which is invisible to runtime descriptive errors. + * + * The snapshots are intentionally exhaustive and inline. Any deliberate + * change to a subpath's public API requires updating the corresponding + * snapshot, which forces the diff to surface in code review. + */ +import { describe, it, expect } from 'vitest' + +import * as redact from '@tanstack/redact' +import * as redactJsxRuntime from '@tanstack/redact/jsx-runtime' +import * as redactDom from '@tanstack/redact/dom' +import * as redactDomClient from '@tanstack/redact/dom-client' +import * as redactDomTestUtils from '@tanstack/redact/dom-test-utils' +import * as redactServer from '@tanstack/redact/server' + +const surface = (mod: Record): Array => + Object.keys(mod).sort() + +describe('public API surface', () => { + it('@tanstack/redact', () => { + expect(surface(redact)).toMatchInlineSnapshot(` + [ + "Children", + "Component", + "Fragment", + "Profiler", + "PureComponent", + "REACT_CONSUMER_TYPE", + "REACT_CONTEXT_TYPE", + "REACT_FORWARD_REF_TYPE", + "REACT_LAZY_TYPE", + "REACT_MEMO_TYPE", + "REACT_PORTAL_TYPE", + "REACT_PROFILER_TYPE", + "REACT_PROVIDER_TYPE", + "REACT_STRICT_MODE_TYPE", + "REACT_SUSPENSE_TYPE", + "ReactSharedInternals", + "StrictMode", + "Suspense", + "act", + "cache", + "cloneElement", + "createContext", + "createElement", + "createRef", + "default", + "forwardRef", + "isValidElement", + "lazy", + "memo", + "startTransition", + "taintObjectReference", + "taintUniqueValue", + "use", + "useActionState", + "useCallback", + "useContext", + "useDebugValue", + "useDeferredValue", + "useEffect", + "useEffectEvent", + "useFormStatus", + "useId", + "useImperativeHandle", + "useInsertionEffect", + "useLayoutEffect", + "useMemo", + "useOptimistic", + "useReducer", + "useRef", + "useState", + "useSyncExternalStore", + "useSyncExternalStoreWithSelector", + "useTransition", + "version", + ] + `) + }) + + it('@tanstack/redact/jsx-runtime', () => { + expect(surface(redactJsxRuntime)).toMatchInlineSnapshot(` + [ + "Fragment", + "jsx", + "jsxDEV", + "jsxs", + ] + `) + }) + + it('@tanstack/redact/dom', () => { + expect(surface(redactDom)).toMatchInlineSnapshot(` + [ + "createPortal", + "default", + "flushSync", + "preconnect", + "prefetchDNS", + "preinit", + "preinitModule", + "preload", + "preloadModule", + "unstable_batchedUpdates", + "version", + ] + `) + }) + + it('@tanstack/redact/dom-client', () => { + expect(surface(redactDomClient)).toMatchInlineSnapshot(` + [ + "createRoot", + "hydrateRoot", + ] + `) + }) + + it('@tanstack/redact/dom-test-utils', () => { + expect(surface(redactDomTestUtils)).toMatchInlineSnapshot(` + [ + "act", + ] + `) + }) + + it('@tanstack/redact/server', () => { + expect(surface(redactServer)).toMatchInlineSnapshot(` + [ + "BOUNDARY_REVEAL_RUNTIME", + "default", + "renderToPipeableStream", + "renderToReadableStream", + "renderToStaticMarkup", + "renderToString", + "version", + ] + `) + }) +}) + +describe('aliased shim names — what `redact()` claims to provide', () => { + /** + * The Vite plugin aliases `use-sync-external-store/shim/with-selector` → + * `@tanstack/redact`, promising consumers that the module exposes + * `useSyncExternalStoreWithSelector`. Likewise for the bare + * `use-sync-external-store` paths. If `@tanstack/redact` ever drops these + * names, every router/store user breaks at link time. This test guards + * the contract. + */ + it('exposes hooks needed by use-sync-external-store/shim/* aliases', () => { + expect(redact).toHaveProperty('useSyncExternalStore') + expect(redact).toHaveProperty('useSyncExternalStoreWithSelector') + expect(typeof (redact as any).useSyncExternalStore).toBe('function') + expect(typeof (redact as any).useSyncExternalStoreWithSelector).toBe( + 'function', + ) + }) +}) diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index 3e46956..551a343 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -11,28 +11,28 @@ export default defineConfig({ }, resolve: { alias: { - '@tanstack/dom-core': r('packages/core/src/index.ts'), - '@tanstack/react/jsx-runtime': r('packages/react/src/jsx-runtime.ts'), - '@tanstack/react/jsx-dev-runtime': r('packages/react/src/jsx-runtime.ts'), - '@tanstack/react': r('packages/react/src/index.ts'), - '@tanstack/react-dom/client': r('packages/react-dom/src/client.ts'), - '@tanstack/react-dom/test-utils': r('packages/react-dom/src/test-utils.ts'), - '@tanstack/react-dom': r('packages/react-dom/src/index.ts'), - '@tanstack/react-dom-server': r('packages/react-dom-server/src/index.ts'), - '@tanstack/scheduler': r('packages/scheduler/src/index.ts'), - '@tanstack/dom-hydration-runtime': r('packages/hydration-runtime/src/index.ts'), - // React-shape aliases (what consumers will actually import) - 'react/jsx-runtime': r('packages/react/src/jsx-runtime.ts'), - 'react/jsx-dev-runtime': r('packages/react/src/jsx-runtime.ts'), - react: r('packages/react/src/index.ts'), - 'react-dom/client': r('packages/react-dom/src/client.ts'), - 'react-dom/server': r('packages/react-dom-server/src/index.ts'), - 'react-dom': r('packages/react-dom/src/index.ts'), - scheduler: r('packages/scheduler/src/index.ts'), + '@tanstack/redact/jsx-runtime': r('packages/redact/src/react/jsx-runtime.ts'), + '@tanstack/redact/jsx-dev-runtime': r('packages/redact/src/react/jsx-runtime.ts'), + '@tanstack/redact/dom-client': r('packages/redact/src/dom/client.ts'), + '@tanstack/redact/dom-test-utils': r('packages/redact/src/dom/test-utils.ts'), + '@tanstack/redact/dom': r('packages/redact/src/dom/index.ts'), + '@tanstack/redact/server': r('packages/redact/src/server/index.ts'), + '@tanstack/redact/scheduler': r('packages/redact/src/scheduler/index.ts'), + '@tanstack/redact/vite': r('packages/redact/src/vite/index.ts'), + '@tanstack/redact': r('packages/redact/src/react/index.ts'), + // React-shape aliases — what consumers will actually import + 'react/jsx-runtime': r('packages/redact/src/react/jsx-runtime.ts'), + 'react/jsx-dev-runtime': r('packages/redact/src/react/jsx-runtime.ts'), + react: r('packages/redact/src/react/index.ts'), + 'react-dom/client': r('packages/redact/src/dom/client.ts'), + 'react-dom/server': r('packages/redact/src/server/index.ts'), + 'react-dom/test-utils': r('packages/redact/src/dom/test-utils.ts'), + 'react-dom': r('packages/redact/src/dom/index.ts'), + scheduler: r('packages/redact/src/scheduler/index.ts'), }, }, esbuild: { jsx: 'automatic', - jsxImportSource: '@tanstack/react', + jsxImportSource: '@tanstack/redact', }, }) diff --git a/tsconfig.json b/tsconfig.json index 3da408d..6158f46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "verbatimModuleSyntax": false, "resolveJsonModule": true, "jsx": "react-jsx", - "jsxImportSource": "@tanstack/react", + "jsxImportSource": "@tanstack/redact", "types": [], "composite": false },