@tanstack/redact 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +481 -0
- package/package.json +5 -5
package/README.md
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# redact
|
|
2
|
+
|
|
3
|
+
**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.
|
|
4
|
+
|
|
5
|
+
- **9.07 KB** gzip at full drop-in parity (vs ~45 KB for React 19)
|
|
6
|
+
- **6.75 KB** gzip with every opt-in feature stubbed (`nano` preset)
|
|
7
|
+
- **707/707** unit + integration tests passing, SSR + streaming Suspense + hydration included
|
|
8
|
+
- Running in production on [tanstack.com](https://tanstack.com) as of 2026-04-20
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add @tanstack/redact@next
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// vite.config.ts
|
|
20
|
+
import { defineConfig } from 'vite'
|
|
21
|
+
import { redact } from '@tanstack/redact/vite'
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
plugins: [redact()],
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { useState, Suspense } from 'react'
|
|
32
|
+
import { createRoot, hydrateRoot } from 'react-dom/client'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Shrink further with feature flags
|
|
36
|
+
|
|
37
|
+
Two presets — pick a starting point, flip flags from there:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
redact({ preset: 'full' }) // 9.07 KB — everything on, opt OUT individual features
|
|
41
|
+
redact({ preset: 'nano' }) // 6.75 KB — everything off, opt IN what you need
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Opt out from `full`:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
redact({
|
|
48
|
+
preset: 'full',
|
|
49
|
+
features: {
|
|
50
|
+
hydration: false, // SPA only — no SSR
|
|
51
|
+
classComponents: false, // function components only
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Opt in from `nano`:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
redact({
|
|
60
|
+
preset: 'nano',
|
|
61
|
+
features: {
|
|
62
|
+
context: true, // bring back just what you need
|
|
63
|
+
suspense: true,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Full feature matrix and alternative configuration paths below.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## How it works in 30 seconds
|
|
73
|
+
|
|
74
|
+
`@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.
|
|
75
|
+
|
|
76
|
+
Feature selection is a bundler-level concern. The `@tanstack/redact/vite` plugin's `resolveId` hook swaps `features/<name>/index.js` → `features/<name>/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.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Feature flags
|
|
81
|
+
|
|
82
|
+
### Feature matrix
|
|
83
|
+
|
|
84
|
+
| Flag | Full behavior | Stub behavior (when `false`) | Savings (gzip) |
|
|
85
|
+
|---|---|---|---:|
|
|
86
|
+
| `portal` | `createPortal` into alt container | Children render in place, `container` ignored | ~30 B |
|
|
87
|
+
| `context` | Provider push/pop + consumer walk | Provider → Fragment; `useContext` returns default | ~80 B |
|
|
88
|
+
| `suspense` | Boundary + fallback + streaming hydration | Suspense → Fragment; thenables retry on settle | **~640 B** |
|
|
89
|
+
| `memo` | `shallowEqual` prop-equality gate | Passes through every parent render | ~80 B |
|
|
90
|
+
| `forwardRef` | Ref forwarded to inner fn | Ref dropped (React 19 "refs as props" still works) | ~70 B |
|
|
91
|
+
| `lazy` | Full hydration coordination | Sync-resolvable payloads work; async retries on settle | ~20 B |
|
|
92
|
+
| `classComponents` | Full lifecycle + `contextType` + error boundaries | `constructor` + `render` + `setState` only | ~200 B |
|
|
93
|
+
| `hydration` | SSR DOM adoption, streaming boundaries, scroll guard, event replay | `hydrateRoot` throws; use `createRoot` for SPA | **~1270 B** |
|
|
94
|
+
|
|
95
|
+
**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.
|
|
96
|
+
|
|
97
|
+
### Presets
|
|
98
|
+
|
|
99
|
+
| Preset | What's on | `react-dom/client` gzip | Intent |
|
|
100
|
+
|---|---|---:|---|
|
|
101
|
+
| `full` (default) | all 8 features | **9.07 KB** | Drop-in React — opt OUT individual features you don't need |
|
|
102
|
+
| **`nano`** | none | **6.75 KB** | Start minimal — opt IN individual features you need |
|
|
103
|
+
|
|
104
|
+
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.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Configuration
|
|
109
|
+
|
|
110
|
+
Four ways to configure, depending on your bundler and ergonomics preference.
|
|
111
|
+
|
|
112
|
+
### 1. Vite plugin (recommended)
|
|
113
|
+
|
|
114
|
+
`@tanstack/redact/vite`'s `redact()` plugin. Covered in [Quick start](#quick-start) above. Full options:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
interface RedactOptions {
|
|
118
|
+
preset?: 'nano' | 'full' // default: 'full'
|
|
119
|
+
features?: {
|
|
120
|
+
portal?: boolean
|
|
121
|
+
context?: boolean
|
|
122
|
+
suspense?: boolean
|
|
123
|
+
memo?: boolean
|
|
124
|
+
forwardRef?: boolean
|
|
125
|
+
lazy?: boolean
|
|
126
|
+
classComponents?: boolean
|
|
127
|
+
hydration?: boolean
|
|
128
|
+
}
|
|
129
|
+
skip?: ReadonlyArray<string> // don't alias these specifiers
|
|
130
|
+
resolveFrom?: string // override package resolution root
|
|
131
|
+
packageRoots?: Record<string, string> // explicit package paths
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
### 2. Bundler aliases (Webpack / Rollup / esbuild / …)
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
141
|
+
**Subpath layout:**
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
@tanstack/redact/features/
|
|
145
|
+
portal/ context/ suspense/ memo/ forward-ref/ lazy/ class/ hydration/
|
|
146
|
+
index ← re-exports from ./full by default
|
|
147
|
+
full ← real implementation
|
|
148
|
+
stub ← graceful degradation
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Webpack example (stubs hydration + suspense):**
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
// webpack.config.js
|
|
155
|
+
module.exports = {
|
|
156
|
+
resolve: {
|
|
157
|
+
alias: {
|
|
158
|
+
'@tanstack/redact/features/hydration/index':
|
|
159
|
+
'@tanstack/redact/features/hydration/stub',
|
|
160
|
+
'@tanstack/redact/features/suspense/index':
|
|
161
|
+
'@tanstack/redact/features/suspense/stub',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Rollup:**
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
import alias from '@rollup/plugin-alias'
|
|
171
|
+
|
|
172
|
+
export default {
|
|
173
|
+
plugins: [
|
|
174
|
+
alias({
|
|
175
|
+
entries: [
|
|
176
|
+
{
|
|
177
|
+
find: '@tanstack/redact/features/hydration/index',
|
|
178
|
+
replacement: '@tanstack/redact/features/hydration/stub',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}),
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**esbuild:**
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { build } from 'esbuild'
|
|
190
|
+
|
|
191
|
+
await build({
|
|
192
|
+
entryPoints: ['src/app.tsx'],
|
|
193
|
+
bundle: true,
|
|
194
|
+
alias: {
|
|
195
|
+
'@tanstack/redact/features/hydration/index':
|
|
196
|
+
'@tanstack/redact/features/hydration/stub',
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**Gotchas:**
|
|
202
|
+
|
|
203
|
+
- **On-disk folder names vs. config keys**: `forward-ref/` ↔ `forwardRef`, `class/` ↔ `classComponents`. When configuring aliases manually, match the on-disk folder.
|
|
204
|
+
- **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.
|
|
205
|
+
- **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).
|
|
206
|
+
|
|
207
|
+
### 3. Prebuilt bundle presets (planned)
|
|
208
|
+
|
|
209
|
+
Not yet shipped. The planned shape:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { createRoot } from '@tanstack/redact/dom/nano/client'
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Zero bundler configuration; useful for script-tag usage, non-bundler Node tools, or users who just want the smallest install without thinking about it.
|
|
216
|
+
|
|
217
|
+
**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.
|
|
218
|
+
|
|
219
|
+
### 4. npm aliases (limited)
|
|
220
|
+
|
|
221
|
+
`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:
|
|
222
|
+
|
|
223
|
+
```jsonc
|
|
224
|
+
// package.json — works, but only swaps `react` itself
|
|
225
|
+
{
|
|
226
|
+
"dependencies": {
|
|
227
|
+
"react": "npm:@tanstack/redact@next"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
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.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Advanced: authoring custom features & bundler plugins
|
|
237
|
+
|
|
238
|
+
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`.
|
|
239
|
+
|
|
240
|
+
### Registration primitives
|
|
241
|
+
|
|
242
|
+
Feature modules self-register by calling these at module load:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
import {
|
|
246
|
+
registerRenderer,
|
|
247
|
+
registerTypeMatcher,
|
|
248
|
+
registerElementMarker,
|
|
249
|
+
type RenderFn,
|
|
250
|
+
type TypeMatcher,
|
|
251
|
+
} from '@tanstack/redact/_all'
|
|
252
|
+
|
|
253
|
+
// Install a renderer for a FiberTag. Later calls overwrite earlier ones —
|
|
254
|
+
// stubs exploit this order-dependence.
|
|
255
|
+
function registerRenderer(tag: FiberTag, fn: RenderFn): void
|
|
256
|
+
|
|
257
|
+
// Add a type matcher. Iterated in registration order during fiber creation,
|
|
258
|
+
// after core checks (string → Host, REACT_FRAGMENT_TYPE → Fragment) and
|
|
259
|
+
// before the function-vs-class fallback.
|
|
260
|
+
type TypeMatcher = (type: any, marker: any) => FiberTag | null
|
|
261
|
+
function registerTypeMatcher(m: TypeMatcher): void
|
|
262
|
+
|
|
263
|
+
// Extend the accepted $$typeof set for child normalization. Default:
|
|
264
|
+
// REACT_ELEMENT_TYPE, REACT_LEGACY_ELEMENT_TYPE. Portal adds REACT_PORTAL_TYPE.
|
|
265
|
+
function registerElementMarker(sym: symbol): void
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Capability hooks
|
|
269
|
+
|
|
270
|
+
Cross-cutting concerns (thrown-thenable handling, context reads) install via `installCapability`:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { installCapability, type Capabilities } from '@tanstack/redact/_all'
|
|
274
|
+
|
|
275
|
+
interface Capabilities {
|
|
276
|
+
handleSuspended: (fiber: Fiber, thenable: Promise<any>) => void
|
|
277
|
+
readContext: (fiber: Fiber, ctx: any) => any
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function installCapability<K extends keyof Capabilities>(
|
|
281
|
+
name: K,
|
|
282
|
+
fn: Capabilities[K],
|
|
283
|
+
): void
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Defaults when no feature installs an override:
|
|
287
|
+
- `handleSuspended`: retry-on-settle (no boundary stack, no fallback)
|
|
288
|
+
- `readContext`: returns `ctx._currentValue` with no provider-tree walk
|
|
289
|
+
|
|
290
|
+
The full Suspense feature installs a boundary-stack-based `handleSuspended`. The full Context feature installs a walking `readContext`.
|
|
291
|
+
|
|
292
|
+
### Authoring a custom feature
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
// my-feature/full.ts
|
|
296
|
+
import {
|
|
297
|
+
FiberTag,
|
|
298
|
+
registerRenderer,
|
|
299
|
+
registerTypeMatcher,
|
|
300
|
+
reconcileChildren,
|
|
301
|
+
childrenToArray,
|
|
302
|
+
type Fiber,
|
|
303
|
+
} from '@tanstack/redact/_all'
|
|
304
|
+
import { SOME_SYMBOL } from '@tanstack/redact'
|
|
305
|
+
|
|
306
|
+
function renderMyThing(fiber: Fiber, domParent: Node, anchor: Node | null): void {
|
|
307
|
+
// your render logic
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
registerTypeMatcher((_type, marker) =>
|
|
311
|
+
marker === SOME_SYMBOL ? FiberTag.SomeTag : null,
|
|
312
|
+
)
|
|
313
|
+
registerRenderer(FiberTag.SomeTag, renderMyThing)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// my-feature/stub.ts
|
|
318
|
+
import { FiberTag, registerTypeMatcher } from '@tanstack/redact/_all'
|
|
319
|
+
import { SOME_SYMBOL } from '@tanstack/redact'
|
|
320
|
+
|
|
321
|
+
// Stub: treat my-thing elements as Fragments (children render normally).
|
|
322
|
+
registerTypeMatcher((_type, marker) =>
|
|
323
|
+
marker === SOME_SYMBOL ? FiberTag.Fragment : null,
|
|
324
|
+
)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Pair with an `index.ts` (`export * from './full'`) and let your bundler pick which to import.
|
|
328
|
+
|
|
329
|
+
### Authoring a bundler plugin
|
|
330
|
+
|
|
331
|
+
The Vite plugin's core is two `resolveId` cases. Port this pattern to any bundler's resolve hook:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
// Case 1: short specifier from features/index.ts
|
|
335
|
+
// Matches `./portal`, `./context`, etc.
|
|
336
|
+
if (importer matches /features[/\\]index\.(ts|js)$/) {
|
|
337
|
+
const name = id.match(/^\.\/([a-z-]+)$/)?.[1]
|
|
338
|
+
if (name && flags[name] === false) {
|
|
339
|
+
return resolveFrom(`./${name}/stub`, importer)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Case 2: resolved-path match for hydration
|
|
344
|
+
// (imported from reconcile, root, suspense/full, lazy/full)
|
|
345
|
+
if (flags.hydration === false && /\/hydration$/.test(id)) {
|
|
346
|
+
const resolved = await resolve(id, importer)
|
|
347
|
+
if (/features[/\\]hydration[/\\]index\.(ts|js)$/.test(resolved)) {
|
|
348
|
+
return resolved.replace(/index\.(ts|js)$/, 'stub.$1')
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Real implementation: [packages/redact/src/vite/index.ts](packages/redact/src/vite/index.ts).
|
|
354
|
+
|
|
355
|
+
### Verifying your setup
|
|
356
|
+
|
|
357
|
+
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/<name>/full.js`. With `hydration: false`, you should NOT see `features/hydration/full.js` or its imports (cursor machinery, event-replay, scroll-guard).
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Scope
|
|
362
|
+
|
|
363
|
+
### Supported
|
|
364
|
+
|
|
365
|
+
- React 19 element model, JSX (classic + automatic), Fragment, Suspense, Portal, Error boundaries, forwardRef, memo, lazy
|
|
366
|
+
- Full hook surface: `useState`, `useReducer`, `useEffect`, `useLayoutEffect`, `useInsertionEffect`, `useMemo`, `useCallback`, `useRef`, `useContext`, `useSyncExternalStore`, `useId`, `useDeferredValue`, `useTransition`, `use` (Context + Promise), `useEffectEvent`
|
|
367
|
+
- Class components with full lifecycle (`componentDidMount`/`componentDidUpdate`/`componentWillUnmount`, `contextType`, `shouldComponentUpdate`, `getDerivedStateFromError`, `componentDidCatch`, legacy lifecycles as no-ops)
|
|
368
|
+
- SSR via `renderToString` / `renderToReadableStream` / `renderToPipeableStream` — including Suspense boundary streaming with `$RC` reveal + event replay
|
|
369
|
+
- Hydration: SSR DOM adoption, deferred hydration for `use(promise)` / lazy, cursor preservation across the synchronous `endHydration`
|
|
370
|
+
- Cohabitation with `@vitejs/plugin-rsc`: the Vite plugin deliberately skips the RSC environment so Flight serialization stays on real `react-server-dom`
|
|
371
|
+
|
|
372
|
+
### Best-effort / subset behavior
|
|
373
|
+
|
|
374
|
+
- `useTransition` / `useDeferredValue` run synchronously — no priority scheduling
|
|
375
|
+
- Scheduler shim is a no-op wrapper around microtasks
|
|
376
|
+
- No time slicing, no lane-based work interruption
|
|
377
|
+
|
|
378
|
+
### Out of scope
|
|
379
|
+
|
|
380
|
+
- `react-server-dom-*/client` Flight deserializer (TanStack Start uses its own seroval-based codec + `@vitejs/plugin-rsc`)
|
|
381
|
+
- React DevTools protocol
|
|
382
|
+
- Behavioral 1:1 parity with React under concurrent-mode stress
|
|
383
|
+
|
|
384
|
+
See [docs/SURFACE.md](./docs/SURFACE.md) for the full React-19 export-by-export audit.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Performance
|
|
389
|
+
|
|
390
|
+
Measured against TanStack Router + TanStack Start benchmarks (`pnpm nx run @benchmarks/client-nav:test:perf:react`, `@benchmarks/ssr:test:perf:react`):
|
|
391
|
+
|
|
392
|
+
| Bench | Real React | This shim | Ratio |
|
|
393
|
+
|---|---:|---:|---:|
|
|
394
|
+
| `client-nav` (router-driven navigation loop) | 34.9 hz | **78.1 hz** | **2.24× faster** |
|
|
395
|
+
| `ssr` (request loop) | ~48 hz | **168 hz** | **~3× faster**[^1] |
|
|
396
|
+
|
|
397
|
+
[^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`).
|
|
398
|
+
|
|
399
|
+
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).
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Development
|
|
404
|
+
|
|
405
|
+
### Layout
|
|
406
|
+
|
|
407
|
+
One package, one tree, internal subdirectories per concern:
|
|
408
|
+
|
|
409
|
+
```
|
|
410
|
+
packages/redact/src/
|
|
411
|
+
core/ VDOM types + symbols (FiberTag, Hook, ReactNode, …)
|
|
412
|
+
react/ 'react' entry: createElement, hooks, context, class,
|
|
413
|
+
memo, suspense, jsx-runtime, ReactSharedInternals
|
|
414
|
+
dom/ 'react-dom' entry: reconciler, host DOM, root,
|
|
415
|
+
createPortal, flushSync
|
|
416
|
+
features/ opt-in features (each is an index/full/stub triple)
|
|
417
|
+
portal/ context/ suspense/ memo/
|
|
418
|
+
forward-ref/ lazy/ class/ hydration/
|
|
419
|
+
server/ 'react-dom/server' entry: renderToString,
|
|
420
|
+
renderToReadableStream, renderToPipeableStream
|
|
421
|
+
scheduler/ 'scheduler' shim (no-op microtask wrapper)
|
|
422
|
+
vite/ redact() Vite plugin: aliases + feature-flag swaps
|
|
423
|
+
tests/ vitest suite — 707 tests
|
|
424
|
+
examples/
|
|
425
|
+
ssr-demo/ full SSR + Suspense streaming smoke app
|
|
426
|
+
docs/
|
|
427
|
+
SURFACE.md React 19 export audit
|
|
428
|
+
SAVINGS_ANALYSIS.md per-export size savings vs React 19
|
|
429
|
+
scripts/
|
|
430
|
+
build.mjs per-entry esbuild build (every TS module emitted)
|
|
431
|
+
size.mjs per-preset / per-flag gzip report
|
|
432
|
+
size-check.mjs CI size-budget assertions
|
|
433
|
+
size-analyze.mjs per-module byte breakdown for a given preset
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Cross-subdir imports inside `packages/redact/src/` use relative paths
|
|
437
|
+
(`../core`, `../react`, etc.). The build emits each TS module as its own
|
|
438
|
+
dist file with all relative imports kept literal — that's what preserves the
|
|
439
|
+
import-graph boundaries the Vite plugin needs to swap features at consumer
|
|
440
|
+
build time.
|
|
441
|
+
|
|
442
|
+
### Commands
|
|
443
|
+
|
|
444
|
+
```bash
|
|
445
|
+
pnpm install
|
|
446
|
+
pnpm build # esbuild dist/ + tsc declaration emit
|
|
447
|
+
pnpm test # vitest suite (707 tests)
|
|
448
|
+
pnpm test:types # tsc --noEmit
|
|
449
|
+
pnpm size # gzip/brotli per entry + per feature-stub
|
|
450
|
+
pnpm size:check # CI budget assertions (fails on regression)
|
|
451
|
+
pnpm --filter ssr-demo dev # serve http://localhost:5173
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Current sizes
|
|
455
|
+
|
|
456
|
+
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/*`.
|
|
457
|
+
|
|
458
|
+
| Entry | min | gzip | brotli |
|
|
459
|
+
|---|---:|---:|---:|
|
|
460
|
+
| `react` (= `@tanstack/redact`) | 6.59 KB | 2.65 KB | 2.41 KB |
|
|
461
|
+
| `react/jsx-runtime` (= `@tanstack/redact/jsx-runtime`) | 247 B | 189 B | 178 B |
|
|
462
|
+
| `react-dom/client` (= `@tanstack/redact/dom-client`, `full`) | 26.56 KB | **9.07 KB** | 8.21 KB |
|
|
463
|
+
| `react-dom/client` (= `@tanstack/redact/dom-client`, `nano`) | 18.75 KB | **6.75 KB** | 6.10 KB |
|
|
464
|
+
| `react-dom/server` (= `@tanstack/redact/server`) | 11.48 KB | 4.59 KB | 4.16 KB |
|
|
465
|
+
| **Client total** (`full`: react + react-dom/client + jsx-runtime) | 32.63 KB | **11.18 KB** | 10.14 KB |
|
|
466
|
+
|
|
467
|
+
Regenerate with `pnpm size`.
|
|
468
|
+
|
|
469
|
+
---
|
|
470
|
+
|
|
471
|
+
## Changelog
|
|
472
|
+
|
|
473
|
+
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.
|
|
474
|
+
|
|
475
|
+
- `@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.
|
|
476
|
+
- `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.
|
|
477
|
+
- `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`.
|
|
478
|
+
- `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.
|
|
479
|
+
- `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.
|
|
480
|
+
- `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.
|
|
481
|
+
</content>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/redact",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "React, redacted. A minimal React-API-compatible drop-in replacement.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/react/index.js",
|
|
@@ -78,10 +78,10 @@
|
|
|
78
78
|
"optional": true
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
|
-
"scripts": {
|
|
82
|
-
"build": "echo done-by-root-build"
|
|
83
|
-
},
|
|
84
81
|
"publishConfig": {
|
|
85
82
|
"access": "public"
|
|
83
|
+
},
|
|
84
|
+
"scripts": {
|
|
85
|
+
"build": "echo done-by-root-build"
|
|
86
86
|
}
|
|
87
|
-
}
|
|
87
|
+
}
|