Skip to content

experimental: migrate from react -> redact (thanks to Tanner Linsley and team)#100

Open
nikilok wants to merge 5 commits into
mainfrom
feat/test-redact
Open

experimental: migrate from react -> redact (thanks to Tanner Linsley and team)#100
nikilok wants to merge 5 commits into
mainfrom
feat/test-redact

Conversation

@nikilok
Copy link
Copy Markdown
Owner

@nikilok nikilok commented May 9, 2026

First-paint JS — preview (@ss/redact) vs production (vanilla React)

Preview — @ss/redact

Chunk raw gzip br
index-MmSRWcLX.js 372.7 KB 101.1 KB 103.2 KB
routes-Cxa-tpbo.js 72.8 KB 24.5 KB 25.7 KB
link-ClPyco1r.js 56.8 KB 19.6 KB 20.4 KB
canonical-CZVYjyxZ.js 43.3 KB 14.4 KB 14.9 KB
Total preload 545.6 KB 159.6 KB 164.2 KB

Production — vanilla React (sponsorsearch.co.uk)

Chunk raw gzip br
index-CvS0zZmT.js 636.3 KB 187.0 KB 190.1 KB
index-Bcwa-P1L.js 75.1 KB 25.3 KB 26.4 KB
Total preload 711.5 KB 212.3 KB 216.5 KB

Deltas

Metric Production Preview Delta
Largest chunk raw 636.3 KB 372.7 KB −263.6 KB / −41.4%
Largest chunk gzip 187.0 KB 101.1 KB −85.9 KB / −45.9%
Total first-paint raw 711.5 KB 545.6 KB −165.9 KB / −23.3%
Total first-paint gzip 212.3 KB 159.6 KB −52.7 KB / −24.8%
Total first-paint brotli 216.5 KB 164.2 KB −52.4 KB / −24.2%

Summary by CodeRabbit

  • New Features

    • Added @ss/redact — a React-compatible runtime with client rendering, hydration, streaming SSR, and suspense reveal support.
    • Integrated a Vite plugin to opt-in/out reconciler features (nano/full presets) and register the runtime with build tooling.
    • Exposed React-compatible DOM, server, and JSX runtimes plus testing helpers.
  • Chores

    • Bumped Vite to ^8.0.3 and @vitejs/plugin-react to ^6.0.1.
    • Added extensive test suites for rendering, hydration, streaming, and integration scenarios.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
learn-tanstack-start Ready Ready Preview, Comment May 9, 2026 6:00pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

📝 Walkthrough

Walkthrough

Adds @ss/redact package with DOM reconciler, features, hydration, SSR/streaming, scheduler, Vite plugin, tests, and integrates it into apps/web via dependency and Vite plugin.

Changes

@ss/redact end-to-end introduction

Layer / File(s) Summary
Core Types & Contracts
packages/redact/src/core/*, packages/redact/src/react/shared-internals.ts
Fiber enums/structures, hook/effect/fiber/root interfaces, and core React-like type shims.
React Facade
packages/redact/src/react/*
Element creation/clone, JSX runtime, hooks wrapper, context, class base, memo/forwardRef/lazy, compiler-runtime, shared internals, and public export surface.
DOM Reconciler & Host
packages/redact/src/dom/**/*
Host prop/event handling, DOM node creation, reconciler, root API, test-utils, and feature wiring.
Feature Modules
packages/redact/src/dom/features/*
Feature implementations and stub variants: portal, context, suspense, memo, forwardRef, lazy, class, hydration (full & stub).
Hydration & Event Replay
packages/redact/src/dom/features/hydration/*, packages/redact/src/dom/event-replay.ts
Hydration cursor, adoption, scroll guard, boundary consumption, and event replay support.
Server Rendering & Streaming
packages/redact/src/server/*
SSR walker, renderToString/static, streaming orchestrator, bootstrap reveal runtime, escaping utilities, SSR dispatcher.
Scheduler
packages/redact/src/scheduler/index.ts
Minimal unstable_* scheduler APIs and priorities.
Vite Plugin
packages/redact/src/vite/index.ts
Plugin providing React aliasing and feature-flag-based stubbing.
Package Setup
packages/redact/package.json, packages/redact/scripts/build.mjs, packages/redact/tsconfig.json
Manifest with exports, build script, TS config.
App Wiring
apps/web/package.json, apps/web/vite.config.ts
Adds dependency and registers redact() plugin.
Tests / Configs
packages/redact/tests/**/*, packages/redact/tests/vitest.config.ts
Comprehensive tests and Vitest/TS configs.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

I hop through fibers, swift and bright,
Weave roots to bloom in server light;
With Vite I map the trails to run,
And tests like stars say “it is done.”
Clip-clop, cache-pop—hydrate with cheer,
A rabbit ships new render gear! 🐇✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/test-redact

@nikilok nikilok changed the title feat: migrate from react -> redact (thanks to Tanner Linsley) feat: migrate from react -> redact (thanks to Tanner Linsley and team) May 9, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (24)
packages/redact/src/react/shared-internals.ts-53-63 (1)

53-63: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Refactor singleton initialization to avoid assignment-in-expression lint error.

Biome flags this as lint/suspicious/noAssignInExpressions; if lint is gating, this will fail CI.

Suggested fix
-export const ReactSharedInternals: SharedInternals =
-  g[KEY] ??
-  (g[KEY] = {
-    H: null,
-    T: null,
-    S: null,
-    currentFiber: null,
-    currentRoot: null,
-    currentHook: null,
-    hookIndex: 0,
-  })
+let shared = g[KEY]
+if (!shared) {
+  shared = {
+    H: null,
+    T: null,
+    S: null,
+    currentFiber: null,
+    currentRoot: null,
+    currentHook: null,
+    hookIndex: 0,
+  }
+  g[KEY] = shared
+}
+
+export const ReactSharedInternals: SharedInternals = shared
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/react/shared-internals.ts` around lines 53 - 63, The
current export uses an assignment inside the nullish coalescing expression
(g[KEY] ?? (g[KEY] = {...})) which triggers the lint rule noAssignInExpressions;
fix by performing the singleton initialization in separate statements: read
g[KEY] into a local variable, if it's falsy create the SharedInternals object
and assign it to g[KEY], then export that local variable as ReactSharedInternals
(referencing ReactSharedInternals, SharedInternals, g, and KEY to locate the
code).
packages/redact/scripts/build.mjs-103-107 (1)

103-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not swallow declaration build failures.

If bun x tsc fails, the package can be published with stale/missing types while CI still appears green. Make this step fail fast.

Suggested fix
   try {
     execSync(`bun x tsc -p ${tsconfigPath}`, { cwd: pkgDir, stdio: 'inherit' })
-  } catch {
-    // Non-fatal: declarations may be incomplete, but JS still builds.
+  } catch (error) {
+    throw new Error(
+      `Type declaration generation failed for `@ss/redact`. Aborting build.`,
+      { cause: error as Error },
+    )
   } finally {
     rmSync(tsconfigPath, { force: true })
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/scripts/build.mjs` around lines 103 - 107, The current
try/catch around the execSync call that runs `bun x tsc -p ${tsconfigPath}`
silently swallows failures; update the try/catch in
packages/redact/scripts/build.mjs so that declaration build failures fail fast
by either removing the catch or capturing the caught error (e) and rethrowing it
(or calling process.exit(1)); specifically modify the block that surrounds
execSync (using symbols execSync, tsconfigPath, pkgDir) to let errors propagate
or explicitly throw the error after logging so CI fails on tsc failures.
packages/redact/src/server/walk.ts-266-269 (1)

266-269: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pop the select context in a finally.

If rendering a <select>'s children throws or suspends, control exits before popSelectContext(). That leaked state can make later <option>s elsewhere in the tree serialize as selected against the wrong parent <select>.

♻️ Suggested fix
   if (isSelect) {
     const val = props.value != null ? props.value : props.defaultValue
     pushSelectContext(val)
   }
@@
-  if (dangerouslyHtml != null) {
-    opts.emit(String(dangerouslyHtml))
-  } else {
-    walkNode(props.children, childOpts)
-  }
-  opts.emit(`</${tag}>`)
-  if (isSelect) popSelectContext()
+  try {
+    if (dangerouslyHtml != null) {
+      opts.emit(String(dangerouslyHtml))
+    } else {
+      walkNode(props.children, childOpts)
+    }
+    opts.emit(`</${tag}>`)
+  } finally {
+    if (isSelect) popSelectContext()
+  }

Also applies to: 345-351

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/walk.ts` around lines 266 - 269, The code pushes
select context with pushSelectContext(val) when isSelect is true but does not
guarantee popSelectContext() runs if rendering children throws or suspends; wrap
the push/pop pair in a try/finally so popSelectContext() always executes (e.g.,
call pushSelectContext(val) then try { /* render children */ } finally {
popSelectContext() }), and apply the same change to the second occurrence
referenced around the push/pop at the other block (the code around lines
345-351) to prevent leaked select state.
packages/redact/src/server/renderToString.ts-31-32 (1)

31-32: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't strip every HTML comment in static markup mode.

This removes user content too, not just hydration markers. Comments coming from CMS HTML, dangerouslySetInnerHTML, or raw script/style payloads will be silently mangled. Strip only the renderer's own marker shapes here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/renderToString.ts` around lines 31 - 32, The
current replacement in the renderToString export indiscriminately strips all
HTML comments from the renderer output (return renderToString(children,
options).replace(...)), which removes user content; change it to only remove the
renderer's own hydration/marker comment shapes instead of every comment. Locate
the return that calls renderToString(children, options) and replace the blanket
comment-strip with a targeted removal that matches only the renderer-specific
marker pattern(s) your renderer emits (use a regex or sanitizer that matches
those unique marker shapes) so CMS/dangerouslySetInnerHTML comments remain
untouched. Ensure the change preserves other behavior of renderToString in this
module.
packages/redact/src/server/walk.ts-437-443 (1)

437-443: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

The fallback wrapper div changes the document structure.

Wrapping every pending boundary in <div id="B:..."> is not neutral HTML. Inside parents like <p>, <table>, <select>, or inline text flows, the browser will reparent or rewrite the DOM before hydration, so the boundary can no longer hydrate against what was emitted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/walk.ts` around lines 437 - 443, emitBoundary
currently wraps the fallback HTML in a visible <div id="B:..."> which mutates
document structure; change it to emit an inert <template id="B:..."> wrapper
instead so the fallback is not reparented or cause invalid nesting (keep the
existing leading/trailing comments used for hydration). Update emitBoundary (and
any callers that rely on the id) to emit: start comment, opening <template
id="B:${id}">, fallbackHTML, closing </template>, end comment via opts.emit;
ensure WalkOptions.emit usage and the id/fallbackHTML parameters remain the
same.
packages/redact/src/server/walk.ts-391-405 (1)

391-405: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Seed textState when rendering Suspense children and fallback.

These nested walkNode() calls bypass walk(), so adjacent text inside a Suspense boundary won't emit the <!-- --> separators that hydration relies on. A boundary containing ['a', 'b'] currently serializes as one text node instead of two.

♻️ Suggested fix
       walkNode(props.children, {
         emit: childEmit,
         onSuspend: opts.onSuspend,
         nextBoundaryId: opts.nextBoundaryId,
+        textState: { lastWasText: false },
       })
@@
         walkNode(props.fallback, {
           emit: (s) => fallbackParts.push(s),
           onSuspend: opts.onSuspend,
           nextBoundaryId: opts.nextBoundaryId,
+          textState: { lastWasText: false },
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/walk.ts` around lines 391 - 405, The nested
walkNode calls for props.children and props.fallback bypass walk's text-state
handling so adjacent text inside a Suspense boundary is merged; fix by
seeding/passing a textState in the options when invoking walkNode (instead of
relying on walk), e.g. derive a childTextState = opts.textState ?? { /* initial
text state */ } and include it in the options passed to walkNode for both
props.children and props.fallback so separators (the <!-- --> markers) are
emitted correctly; update the walkNode invocations referenced in the try/catch
around props.children and inside the fallback handling to pass this textState.
packages/redact/src/scheduler/index.ts-23-49 (1)

23-49: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Update unstable_scheduleCallback to match the public scheduler API contract.

The callback must receive a didTimeout: boolean parameter and handle continuation functions. Currently, fn() is invoked with no arguments and the return value is ignored. The correct signature is:

(didTimeout: boolean) => void | ((didTimeout: boolean) => void | null)

Libraries relying on actual scheduler semantics (timeout tracking, work resumption) will break with this shim.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/scheduler/index.ts` around lines 23 - 49,
unstable_scheduleCallback currently invokes task.callback with no args and
ignores its return; update the run closure in unstable_scheduleCallback to call
the callback with a didTimeout boolean (true when invoked via setTimeout, false
for microtask), capture its return which may be a continuation function or null,
and if a continuation is returned set task.callback to that continuation (bound
to accept didTimeout on next run) so work can resume; preserve cancellation
checks (task.cancelled) and keep the existing error re-throw logic via
setTimeout, and ensure when no continuation is returned you clear task.callback
to null.
packages/redact/src/server/stream.ts-92-95 (1)

92-95: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid reporting the same fatal error twice.

streamHtml() already calls options.onError before rethrowing. The rejection branch in renderToPipeableStream() calls it again after shellReady, so the Node API double-logs the same failure.

Suggested fix
     (err) => {
       if (!shellReady) {
         options.onShellError?.(err)
       } else {
-        options.onError?.(err)
         if (dest) dest.end()
       }
     },

Also applies to: 276-280

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 92 - 95, streamHtml()
already invokes options.onError before rethrowing, causing duplicate error
reports when renderToPipeableStream() also calls options.onError in its
rejection path; modify renderToPipeableStream() so that when state.errored is
set (the path where streamHtml() has already called options.onError) it does not
call options.onError again—use the existing state.errored sentinel (and the same
branch around shellReady/rejection handling) to guard the extra options.onError
invocation and only call options.onError from renderToPipeableStream() when
state.errored is not already set.
packages/redact/src/server/stream.ts-234-238 (1)

234-238: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire options.signal into the pipeable path too.

PipeableOptions inherits signal, but renderToPipeableStream() never subscribes to it. Callers using the same cancellation path as renderToReadableStream() get a no-op on Node unless they also remember to call handle.abort().

Suggested fix
 export function renderToPipeableStream(
   children: ReactNode,
   options: PipeableOptions = {},
 ): PipeableHandle {
@@
+  const abort = () => {
+    aborted = true
+    state.closed = true
+    if (dest) dest.end()
+  }
+
+  if (options.signal?.aborted) abort()
+  else options.signal?.addEventListener('abort', abort, { once: true })
+
   return {
@@
     abort(_reason?: unknown) {
-      aborted = true
-      state.closed = true
-      if (dest) dest.end()
+      abort()
     },
   }
 }

Also applies to: 240-307

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 234 - 238, The
PipeableOptions.signal is not wired into renderToPipeableStream, so callers
using an AbortSignal get no cancellation; update renderToPipeableStream to
subscribe to options.signal (if present), add an 'abort' listener that invokes
the same abort/cleanup path used by renderToReadableStream (e.g., call the
stream/controller/handle abort method or the internal cleanup function used by
renderToReadableStream), and ensure the listener is removed when
onShellReady/onAllReady/onShellError or final cleanup runs; reference
PipeableOptions, renderToPipeableStream, options.signal and the existing
abort/cleanup handler so the cancellation behavior matches the readable-stream
path.
packages/redact/src/server/stream.ts-150-164 (1)

150-164: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor bootstrapScripts[].async.

bootstrapTag() always emits async for classic scripts, so { async: false } can never be expressed. That changes bootstrap execution ordering and breaks the public option contract.

Suggested fix
 function bootstrapTag(
   entry: string | { src: string; async?: boolean; nonce?: string },
   kind: 'script' | 'module',
   defaultNonce: string | undefined,
 ): string {
@@
   const nAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : ''
   const srcAttr = escapeAttr(src)
   if (kind === 'module') return `<script type="module"${nAttr} src="${srcAttr}"></script>`
-  return `<script async${nAttr} src="${srcAttr}"></script>`
+  const asyncAttr =
+    typeof entry === 'string' || entry.async !== false ? ' async' : ''
+  return `<script${asyncAttr}${nAttr} src="${srcAttr}"></script>`
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 150 - 164, bootstrapTag
currently always emits the async attribute for classic scripts, ignoring an
entry object's async flag and breaking bootstrapScripts[].async semantics;
change bootstrapTag to compute an isAsync boolean (e.g., treat string entries as
async by default, but for object entries use entry.async if provided) and only
include the " async" token in the returned classic-script tag when isAsync is
true (leave module branch unchanged); update the classic return that builds the
tag (the code using nAttr and srcAttr) to conditionally insert the async
attribute based on that computed value.
apps/web/package.json-55-56 (1)

55-56: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clarify PR intent—React is actively used throughout the codebase.

The PR title indicates a migration from React to redact, but the code shows the opposite: React remains heavily integrated with 17 direct imports across routes, hooks, and components (useState, useEffect, useRef, useCallback, useId, Suspense, createPortal, flushSync). Meanwhile, @ss/redact is not imported anywhere in the codebase.

Either the PR title is incorrect, or the migration is incomplete. Clarify:

  1. Is this PR actually migrating to redact, or does it serve a different purpose?
  2. If redact is intended as a replacement, why does the implementation still rely on React?
  3. If they must coexist, document the transition plan.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/package.json` around lines 55 - 56, The PR claims a migration to
"@ss/redact" but package.json still lists "react" and "react-dom" and the
codebase continues to import React APIs (useState, useEffect, useRef,
useCallback, useId, Suspense, createPortal, flushSync) while there are no
imports of `@ss/redact`; update the PR to accurately reflect intent: either (A)
actually migrate by replacing React usage with `@ss/redact` equivalents and
remove/adjust "react" and "react-dom" entries in package.json, ensuring new
imports of "@ss/redact" appear in the components/hooks that currently use
useState/useEffect/etc., or (B) if React is to remain, change the PR
title/description to remove "migration" language and add a short
coexistence/transition plan documenting which modules will convert to `@ss/redact`
and a timeline; ensure consistency between package.json dependencies and actual
imports (react/react-dom vs `@ss/redact`) and update documentation accordingly.
packages/redact/tests/create-root-clear-container.test.tsx-17-18 (1)

17-18: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Import createRoot from @ss/redact/dom-client instead of react-dom/client.

The test uses jsxImportSource: "@ss/redact", which means JSX elements compile to @ss/redact runtime calls. However, createRoot is imported from React's react-dom/client (line 18). This creates an inconsistency—the test should import from @ss/redact/dom-client to match the JSX compilation and test @ss/redact's createRoot API consistently.

Change line 18 to:

import { createRoot } from '@ss/redact/dom-client'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/create-root-clear-container.test.tsx` around lines 17 -
18, The test imports createRoot from react-dom/client but the file is compiled
with jsxImportSource "@ss/redact", so update the import to use the redact DOM
runtime: replace the existing import of createRoot from 'react-dom/client' with
import { createRoot } from '@ss/redact/dom-client' so the test uses `@ss/redact`'s
createRoot implementation consistently with the JSX runtime.
apps/web/vite.config.ts-15-15 (1)

15-15: ⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

Remove viteReact() and configure jsxImportSource in tsconfig.json.

Given the PR objective to migrate from React to redact, keeping @vitejs/plugin-react active (line 51) creates risk and confusion:

  1. The redact vite plugin (line 15) aliases react/jsx-runtime@ss/redact/jsx-runtime and is designed as a React drop-in replacement.
  2. viteReact() is a Babel-based React JSX transformer that operates independently of redact's aliases, creating potential JSX transform conflicts.
  3. tsconfig.json is missing jsxImportSource: "@ss/redact"—the jsx-runtime.ts file explicitly expects this configuration (see comment at line 18-19), but tsconfig only specifies jsx: "react-jsx" without it. This causes TypeScript to default to "react" instead of @ss/redact.

If redact is intended as a full React replacement, remove viteReact() and add jsxImportSource: "@ss/redact" to apps/web/tsconfig.json under compilerOptions. If both plugins are intentionally required (e.g., for fast refresh or experimental features), document the rationale and ensure explicit JSX configuration is in place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/vite.config.ts` at line 15, The build currently mixes the redact
Vite plugin and `@vitejs/plugin-react` which can conflict: remove the viteReact()
plugin from the Vite plugins array (leave redact() in place) and update the
TypeScript config under compilerOptions to add "jsxImportSource": "@ss/redact"
alongside jsx: "react-jsx" so the JSX runtime resolves to `@ss/redact`; if you
intentionally need both plugins, instead document the rationale in a comment and
ensure tsconfig's jsxImportSource is set to "@ss/redact" anyway.
packages/redact/src/dom/features/memo/full.ts-14-23 (1)

14-23: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Match React's shallow prop comparison.

This comparator returns true for different prop shapes like { a: undefined } vs { b: undefined } because it doesn't check that b owns each key from a. It also diverges from React on NaN and -0 because it uses === instead of Object.is.

React-compatible comparator
 function shallowEqual(a: any, b: any): boolean {
-  if (a === b) return true
+  if (Object.is(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
+    if (!Object.prototype.hasOwnProperty.call(b, k) || !Object.is(a[k], b[k])) {
+      return false
+    }
   }
   return true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/memo/full.ts` around lines 14 - 23, The
shallowEqual implementation incorrectly treats different shapes like {a:
undefined} vs {b: undefined} as equal and uses === which mishandles NaN and -0;
update the shallowEqual function to (1) ensure both inputs are non-null objects
before comparing, (2) after comparing key lengths iterate keys from
Object.keys(a) and for each key verify Object.prototype.hasOwnProperty.call(b,
key) (so b actually owns the key), and (3) use Object.is(a[key], b[key]) instead
of === to match React's semantics; keep the initial fast-path a === b check and
the existing key-length check in shallowEqual.
packages/redact/src/dom/features/context/full.ts-48-54 (1)

48-54: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Extend type matcher to handle React 19 Context-as-Provider syntax.

The type matcher only recognizes REACT_PROVIDER_TYPE and REACT_CONSUMER_TYPE markers. React 19 supports rendering Context objects directly as providers using <MyContext value={x}> instead of <MyContext.Provider value={x}>. In this case, the JSX type is the Context object itself with $$typeof === REACT_CONTEXT_TYPE.

When a Context object flows through the matcher, it returns null and falls back to the typeof type === 'function' check. Since Context objects are plain objects (not functions), they would default to FiberTag.Host instead of FiberTag.Provider, breaking context propagation for descendants.

Add a case to map REACT_CONTEXT_TYPE to FiberTag.Provider:

Suggested fix
registerTypeMatcher((_type, marker) =>
  marker === REACT_PROVIDER_TYPE
    ? FiberTag.Provider
    : marker === REACT_CONSUMER_TYPE
      ? FiberTag.Consumer
      : marker === REACT_CONTEXT_TYPE
        ? FiberTag.Provider
        : null,
)

Also import REACT_CONTEXT_TYPE from ../../../react.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/context/full.ts` around lines 48 - 54, The
type matcher registered via registerTypeMatcher currently maps
REACT_PROVIDER_TYPE and REACT_CONSUMER_TYPE but misses React 19's
Context-as-Provider objects, causing Context objects (with $$typeof ===
REACT_CONTEXT_TYPE) to fall through to Host; update the matcher to also return
FiberTag.Provider when marker === REACT_CONTEXT_TYPE and add an import for
REACT_CONTEXT_TYPE from ../../../react so Context objects are recognized as
Provider; keep the existing returns for REACT_PROVIDER_TYPE and
REACT_CONSUMER_TYPE and only add the new branch mapping to FiberTag.Provider.
packages/redact/src/react/index.ts-111-149 (1)

111-149: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the default export in sync with the named surface.

useSyncExternalStoreWithSelector is exported on Lines 35-35 but never added to the default object, so React.useSyncExternalStoreWithSelector breaks even though the named import works. Deriving the default export from the named bindings would avoid this drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/react/index.ts` around lines 111 - 149, The default
export object is missing the useSyncExternalStoreWithSelector symbol (exported
as a named binding), causing React.useSyncExternalStoreWithSelector to be
undefined; update the default export in this module to include
useSyncExternalStoreWithSelector (or derive the default export programmatically
from the named exports) so the default object contains the same surface as the
named exports (e.g., include useSyncExternalStoreWithSelector alongside
createElement, useState, useSyncExternalStore, etc.).
packages/redact/src/dom/features/suspense/full.ts-119-123 (1)

119-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mark streamed pending boundaries as suspended.

For kind === 'pending', storing suspended: false lets any rerender before $RH fires try props.children against a boundary whose real DOM has not been revealed yet. That can mount real content early and then tear it back down again when rehydrateBoundary() runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/suspense/full.ts` around lines 119 - 123,
The memoizedState for streamed boundaries incorrectly sets suspended: false for
pending boundaries; change the initialization in the fiber.memoizedState
assignment so that when the boundary kind is 'pending' it sets suspended: true
(e.g., suspended: kind !== 'pending' or an explicit conditional), ensuring
pending streamed boundaries are treated as suspended until rehydrateBoundary()
runs.
packages/redact/src/dom/features/hydration/full.ts-163-166 (1)

163-166: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat a missing keyed head attribute as a match.

The comment says “if one defines it, they must match”, but this branch currently accepts candidates where one side is missing href/name/src/etc. That can claim the wrong <link>/<meta>/<script> node and suppress mounting the real one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/hydration/full.ts` around lines 163 - 166,
The current logic treats a missing keyed attribute as a match; keep the check
that skips when both propVal and elVal are null but change the tolerant branch
that allows one side to be missing: in the hydration comparison block replace
the "if (propVal == null || elVal == null) continue" behavior so that if exactly
one of propVal or elVal is null it returns false (non-match) instead of
continuing, ensuring the element with a missing keyed attribute cannot be
claimed as a match.
packages/redact/src/dom/features/hydration/full.ts-207-217 (1)

207-217: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track nesting when searching for the closing Suspense marker.

This stops at the first <!--/$--> after startMark, so an outer streamed boundary containing an inner boundary will pair with the inner close marker and corrupt the hydration cursor range. The scan needs a depth counter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/hydration/full.ts` around lines 207 - 217,
The scan for the closing Suspense marker must track nested boundaries: change
the loop that walks from startMark.nextSibling to maintain a depth counter
(initialize depth = 0); when you see a comment node with (comment as
Comment).data === '$' increment depth, when you see (comment as Comment).data
=== '/$' decrement and if decrementing brings depth to -1 or if depth is 0 when
encountering '/$' treat that '/$' as the matching endMark (set endMark = scan
and break). Update references in the loop that currently only checks for '/$' so
nested opening markers (Comment.data === '$') are accounted for and the correct
closing marker is selected.
packages/redact/src/dom/features/suspense/full.ts-175-185 (1)

175-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset hydration state in a finally.

reconcileChildren() can still throw here. If it does, clearHydrationCursor(fiber) and root.hydrating = false are skipped, leaving the root stuck in hydration mode and poisoning later updates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/features/suspense/full.ts` around lines 175 - 185,
The block that sets root.hydrating and calls reconcileChildren can throw and
currently skips cleanup; wrap the hydrating region so that after creating the
HydrationCursor and calling setHydrationCursor and reconcileChildren you always
run clearHydrationCursor(fiber) and reset root.hydrating = false in a finally
block. Concretely, keep creating the HydrationCursor and calling
setHydrationCursor(fiber, cursor) then call reconcileChildren(...) inside a try,
put clearHydrationCursor(fiber) and root.hydrating = false in the finally, and
call runEffects(root) after the try/finally to ensure the hydration state is
never left set on the root (referencing withCurrentRoot, HydrationCursor,
setHydrationCursor, reconcileChildren, clearHydrationCursor, root.hydrating, and
runEffects).
packages/redact/src/dom/dispatcher.ts-241-272 (1)

241-272: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-subscribe when the store callbacks change.

The subscription is installed only once (when hook.cleanup == null), so if a component switches stores or changes its subscribe/getSnapshot after mount, the old subscription persists and the component never resubscribes to the new source. Add dependency tracking to detect changes in the subscribe and getSnapshot parameters and re-subscribe accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/dispatcher.ts` around lines 241 - 272, The current
subscription is only set when hook.cleanup == null, so changes to the
subscribe/getSnapshot functions after mount won't re-subscribe; modify the logic
in the block that uses hook.cleanup, getSnapshot and subscribe to track previous
subscribe/getSnapshot on the hook (e.g., store hook._subscribe and
hook._getSnapshot), compare them to the current subscribe and getSnapshot, and
if they differ call the existing cleanup (if any) to unsubscribe, clear
hook.cleanup, and proceed to re-create forceUpdate, call subscribe to get a new
unsubscribe, assign hook.cleanup and update the stored
hook._subscribe/hook._getSnapshot; ensure you still push the unsubscribe onto
fiber.cleanups and preserve the isHydrating/getServerSnapshot post-hydration
queueMicrotask behavior.
packages/redact/src/dom/dispatcher.ts-204-210 (1)

204-210: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the useId counter to the root instead of module-level scope.

The global idCounter couples independent roots together. When multiple roots use the same identifierPrefix (the default :r), they generate different IDs for identical subtrees based on mount order, causing collisions and hydration mismatches. Each root should maintain its own ID counter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/dispatcher.ts` around lines 204 - 210, The
module-level idCounter causes cross-root ID coupling; instead store and
increment the counter on the root object. In useId(), after getting the current
fiber and root via getCurrentFiber() and findRootFromFiber(), initialize a
per-root counter field (e.g., root.idCounter or root.__idCounter) if undefined
and use that to build the id with root.identifierPrefix, incrementing that root
counter for each new id; remove reliance on the module-scoped idCounter so
identical subtrees in different roots produce stable, root-local ids.
packages/redact/src/react/class.ts-30-34 (1)

30-34: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make setState silently ignore calls when unmounted, consistent with forceUpdate behavior.

setState throws "Cannot call setState on an unmounted component", but forceUpdate (line 37–40) silently returns when the updater hook is absent. This inconsistency breaks React-compatible code: async setState calls from unresolved promises or timers after unmount will crash the app instead of being ignored. The framework's own external-store-unmount.test.tsx demonstrates the design philosophy—post-unmount callbacks should be safely ignored, not throw.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/react/class.ts` around lines 30 - 34, The setState method
currently throws when this._enqueueUpdate is missing; change it to mirror
forceUpdate by silently returning when unmounted instead of throwing. In the
setState implementation (method name: setState, symbol: this._enqueueUpdate),
remove the throw and add an early return when this._enqueueUpdate is falsy so
callers from async promises/timers are ignored safely; otherwise call
this._enqueueUpdate(updater, callback) as before.
packages/redact/src/dom/dispatcher.ts-189-197 (1)

189-197: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track ref parameter changes separately from deps.

The early return on stable deps prevents ref assignment from running, so a new ref object/function never receives the handle value. useImperativeHandle should update the ref even when the handle is memoized, since the ref parameter can change independently of the dependency array.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/dispatcher.ts` around lines 189 - 197, The early
return in useImperativeHandle prevents assigning the handle to a newly passed
ref when deps are stable; modify the hook state to track hook.ref and hook.value
(in addition to hook.deps) retrieved via nextHook(), and change the logic so
that if depsEqual(hook.deps, deps) you still check whether ref !== hook.ref — if
the ref changed, assign the existing hook.value to the new ref (call ref(value)
or set ref.current) without re-running factory; if deps differ, call factory(),
set hook.value and hook.deps and then assign to ref; ensure you update hook.ref
whenever you assign so future calls can detect ref changes.
🟡 Minor comments (9)
packages/redact/tests/synthetic-event-shape.test.tsx-64-64 (1)

64-64: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add explicit type for the button

Line 64 uses <button> without type, which triggers the Biome a11y rule and can default to unintended submit behavior.

Suggested patch
-      return <button onClick={(e: any) => (captured = e)}>go</button>
+      return <button type="button" onClick={(e: any) => (captured = e)}>go</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/synthetic-event-shape.test.tsx` at line 64, The test's
JSX returns a bare <button> which can default to submit and trigger the a11y
rule; update the returned element in the test (the component/return that sets
captured via onClick) to include an explicit type="button" attribute so it won't
act as a submit button—modify the JSX where captured is assigned (the onClick
handler that sets captured = e) to add the type property.
packages/redact/tests/ssr.test.tsx-78-80 (1)

78-80: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an explicit button type in this test.

This <button> defaults to type="submit" and trips the a11y lint, even though the assertion only cares about event-handler stripping.

Minimal fix
-    expect(renderToString(<button onClick={() => {}}>x</button>)).toBe('<button>x</button>')
+    expect(renderToString(<button type="button" onClick={() => {}}>x</button>)).toBe(
+      '<button type="button">x</button>',
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/ssr.test.tsx` around lines 78 - 80, The test "skips
event handlers" uses a plain <button> which defaults to type="submit" and
triggers the a11y lint; update the JSX passed to renderToString in that test to
include an explicit type="button" attribute on the <button> element so the
assertion still verifies event-handler stripping but the button is not treated
as a submit control.
packages/redact/tests/memo-state-rerender.test.tsx-43-45 (1)

43-45: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return void from the unsubscribe callback.

The teardown currently returns Set.delete()’s boolean, which trips Biome’s callback-return rule and widens the cleanup contract from () => void to a value-returning function.

Minimal fix
     subscribe: (cb) => {
       listeners.add(cb)
-      return () => listeners.delete(cb)
+      return () => {
+        listeners.delete(cb)
+      }
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/memo-state-rerender.test.tsx` around lines 43 - 45, The
unsubscribe callback currently returns Set.delete(cb)'s boolean; update the
subscribe implementation so the returned teardown is a void-returning function
(e.g., return () => { listeners.delete(cb); } or return () => { void
listeners.delete(cb); }) to ensure subscribe's cleanup callback signature
remains () => void; target the subscribe implementation that captures listeners
and returns the unsubscribe closure.
packages/redact/tests/memo-state-rerender.test.tsx-141-150 (1)

141-150: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a non-anchor element for this test host.

This node is only a render marker, so <a> without href adds link semantics the test does not need and fails the a11y lint.

Minimal fix
-      return <a data-testid="leaf">{v}</a>
+      return <span data-testid="leaf">{v}</span>

Update the later selectors from a[data-testid="leaf"] to either span[data-testid="leaf"] or just [data-testid="leaf"].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/memo-state-rerender.test.tsx` around lines 141 - 150,
The test uses an anchor element in the Leaf component causing unnecessary link
semantics and a11y lint failures; change the JSX in function Leaf to render a
non-anchor (e.g., a span or a neutral element) for the test marker and update
any test selectors that query "a[data-testid=\"leaf\"]" to either
"span[data-testid=\"leaf\"]" or simply "[data-testid=\"leaf\"]" so selectors
match the new element; locate the Leaf function and the corresponding test
assertions/selectors in the memo-state-rerender.test.tsx file and make these two
consistent replacements.
packages/redact/tests/floating-ui-pattern.test.tsx-108-113 (1)

108-113: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Give the trigger button an explicit type.

This button is not submitting anything, and leaving the default submit type in place trips the a11y lint for no gain.

Minimal fix
-          <button ref={setReference}>trigger</button>
+          <button type="button" ref={setReference}>trigger</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/tests/floating-ui-pattern.test.tsx` around lines 108 - 113,
The trigger button in the App component created by useFloating currently has no
explicit type and defaults to "submit", causing accessibility linter failures;
update the <button ref={setReference}> in the App function to include an
explicit type attribute (type="button") so it won't act as a form submitter,
leaving useFloating, setReference and setFloating unchanged.
packages/redact/src/dom/root.ts-68-87 (1)

68-87: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Comment overstates the implementation — documentElement is not "adopted as a child."

The comment claims "we adopt documentElement as a CHILD of the root, not as the root itself." However, the code simply assigns target (the Document) directly to rootFiber.dom. During hydration, the existing documentElement is reused in-place, not created as a child fiber. The tests confirm this works correctly: doc.documentElement remains the same reference before and after hydrateRoot, and content renders into it without duplicate <html> elements.

Clarify the comment to match the actual behavior: the root's container is the Document, and hydration reuses the existing documentElement rather than nesting it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/root.ts` around lines 68 - 87, The current comment
incorrectly says we "adopt documentElement as a CHILD of the root"; update the
comment near the target/rootFiber.dom assignment to state that the root's
container can be the Document and that during hydration we reuse the existing
document.documentElement in-place (not creating a child fiber or nesting
<html>), so rootFiber.dom/ rootFiber.stateNode are set to the Document (target)
and hydrateRoot reuses documentElement; reference the target variable and
assignments to rootFiber.dom and rootFiber.stateNode when making the
clarification.
packages/redact/src/react/hooks.ts-93-109 (1)

93-109: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stub form-action hooks silently swallow user actions — consider adding a dev warning so misuse is observable.

useActionState, useFormStatus, and useOptimistic all return fixed placeholders, and the dispatch functions (() => {}) silently discard payloads. Any consumer code that wires these into <form action={...}> or progressive-enhancement flows will appear to work (no exceptions, no type errors) but will never execute the action.

If these are deliberate stubs while the broader Action support is built out, a single dev-mode console.warn at first invocation makes the gap discoverable instead of producing silent UX bugs in apps under migration:

🛡️ Suggested guard
 export function useActionState<S, P>(
   _action: (state: Awaited<S>, payload: P) => S | Promise<S>,
   initial: Awaited<S>,
 ): [Awaited<S>, (payload: P) => void, boolean] {
+  if (process.env.NODE_ENV !== 'production') {
+    console.warn(
+      '[`@ss/redact`] useActionState is a stub in this build; the action will not be invoked.',
+    )
+  }
   return [initial, () => {}, false]
 }

(Same pattern for useFormStatus / useOptimistic.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/react/hooks.ts` around lines 93 - 109, The three stub
hooks (useActionState, useFormStatus, useOptimistic) silently swallow actions;
add a one-time dev-mode warning in each hook (useActionState, useFormStatus,
useOptimistic) that runs only when not in production (e.g. NODE_ENV !==
'production' or similar) to alert developers that these are stubs and dispatch
callbacks are no-ops, ensure the warning is emitted on first invocation only
(use a module-scoped boolean flag per hook) and keep the existing return shapes
so behavior in production remains unchanged.
packages/redact/src/dom/reconcile.ts-405-410 (1)

405-410: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Variable name unkeyedNew is misleading — either rename with documentation or restrict the count to unkeyed children only.

unkeyedOld counts only unkeyed existing fibers (line 407), but unkeyedNew counts all non-null new children, including keyed ones (line 409). This asymmetry feeds into budget = unkeyedNew - unkeyedOld, which the cursor walk uses at type-mismatch sites.

The comment on line 392 states the walk is "unkeyed only," yet the counting logic includes keyed children. The variable name strongly suggests it counts unkeyed children exclusively, making this a maintenance risk — a future reviewer will likely "fix" it to match the name and inadvertently break the heuristic.

If the all-non-null count is intentional (to approximate available slots for the budget heuristic), rename to something like newSlotsTotal and add a comment explaining why keyed children are included in the budget calculation. If it's an oversight, restrict the count to unkeyed entries and run the reconciler tests to confirm no keyed-+-unkeyed-mix scenarios regress.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/reconcile.ts` around lines 405 - 410, The code
incorrectly names and computes unkeyedNew (it currently counts all non-null
newChildren while unkeyedOld counts only unkeyed existing fibers), causing a
misleading budget used by the cursor walk; either (A) restrict the new count to
unkeyed children only (mirror unkeyedOld) so unkeyedNew truly represents unkeyed
new children, or (B) if including keyed children was intentional, rename
unkeyedNew to something like newSlotsTotal and add a comment explaining why
keyed children are counted for the budget; update the calculation of budget
(budget = newSlotsTotal - unkeyedOld or budget = unkeyedNew - unkeyedOld
accordingly) and run the reconciler tests to verify no regressions (refer to
variables unkeyedOld, unkeyedNew, budget, existing, newChildren).
packages/redact/src/react/hooks.ts-142-189 (1)

142-189: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Defer inst mutations to useEffect to match React's reference implementation.

React's useSyncExternalStoreWithSelector reference implementation writes inst.hasValue and inst.value inside a useEffect(() => { inst.hasValue = true; inst.value = value }, [value]). The current code performs these mutations inline during render (lines 187–188), which creates two risks:

  1. In a sync renderer with Suspense paths (as in reconcile.ts), if a render aborts or is discarded, inst retains a value that was never committed. The next render's isEqual gate then uses this phantom "previous selection" that never reached the user.
  2. Side-effects during render are an anti-pattern that React's reference implementation explicitly avoids.

Align with React's implementation—useEffect is already exported from this module:

Proposed fix
   const value = useSyncExternalStore(subscribe, getSelection, getServerSelection)
   useDebugValue(value)
-  inst.hasValue = true
-  inst.value = value
+  useEffect(() => {
+    inst.hasValue = true
+    inst.value = value
+  }, [value])
   return value
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/react/hooks.ts` around lines 142 - 189, The render
currently mutates the render-time ref (inst.hasValue and inst.value) after
calling useSyncExternalStore, which risks using a non-committed selection and
causes side-effects during render; move those assignments into a useEffect so
the ref is updated only after commit: remove the inline mutations inst.hasValue
= true and inst.value = value and add a useEffect(() => {
instRef.current!.hasValue = true; instRef.current!.value = value }, [value])
(referencing instRef/inst, value, useSyncExternalStore,
getSelection/getServerSelection) so updates happen post-commit and mirror
React's useSyncExternalStoreWithSelector behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d71b8cda-a2de-444c-ac52-48de22027270

📥 Commits

Reviewing files that changed from the base of the PR and between d95f9fc and 36764b4.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (106)
  • apps/web/package.json
  • apps/web/vite.config.ts
  • packages/redact/package.json
  • packages/redact/scripts/build.mjs
  • packages/redact/src/core/index.ts
  • packages/redact/src/core/internal.ts
  • packages/redact/src/core/types.ts
  • packages/redact/src/dom/_all.ts
  • packages/redact/src/dom/client.ts
  • packages/redact/src/dom/dispatcher.ts
  • packages/redact/src/dom/dom.ts
  • packages/redact/src/dom/event-replay.ts
  • packages/redact/src/dom/features/class/full.ts
  • packages/redact/src/dom/features/class/index.ts
  • packages/redact/src/dom/features/class/stub.ts
  • packages/redact/src/dom/features/context/full.ts
  • packages/redact/src/dom/features/context/index.ts
  • packages/redact/src/dom/features/context/stub.ts
  • packages/redact/src/dom/features/forward-ref/full.ts
  • packages/redact/src/dom/features/forward-ref/index.ts
  • packages/redact/src/dom/features/forward-ref/stub.ts
  • packages/redact/src/dom/features/hydration/full.ts
  • packages/redact/src/dom/features/hydration/index.ts
  • packages/redact/src/dom/features/hydration/stub.ts
  • packages/redact/src/dom/features/index.ts
  • packages/redact/src/dom/features/lazy/full.ts
  • packages/redact/src/dom/features/lazy/index.ts
  • packages/redact/src/dom/features/lazy/stub.ts
  • packages/redact/src/dom/features/memo/full.ts
  • packages/redact/src/dom/features/memo/index.ts
  • packages/redact/src/dom/features/memo/stub.ts
  • packages/redact/src/dom/features/portal/full.ts
  • packages/redact/src/dom/features/portal/index.ts
  • packages/redact/src/dom/features/portal/stub.ts
  • packages/redact/src/dom/features/suspense/full.ts
  • packages/redact/src/dom/features/suspense/index.ts
  • packages/redact/src/dom/features/suspense/stub.ts
  • packages/redact/src/dom/index.ts
  • packages/redact/src/dom/portal.ts
  • packages/redact/src/dom/reconcile.ts
  • packages/redact/src/dom/root.ts
  • packages/redact/src/dom/test-utils.ts
  • packages/redact/src/react/children.ts
  • packages/redact/src/react/class.ts
  • packages/redact/src/react/compiler-runtime.ts
  • packages/redact/src/react/context.ts
  • packages/redact/src/react/element.ts
  • packages/redact/src/react/hooks.ts
  • packages/redact/src/react/index.ts
  • packages/redact/src/react/jsx-runtime.ts
  • packages/redact/src/react/memo.ts
  • packages/redact/src/react/portal.ts
  • packages/redact/src/react/shared-internals.ts
  • packages/redact/src/react/suspense.ts
  • packages/redact/src/scheduler/index.ts
  • packages/redact/src/server/bootstrap-script.ts
  • packages/redact/src/server/dispatcher.ts
  • packages/redact/src/server/escape.ts
  • packages/redact/src/server/index.ts
  • packages/redact/src/server/renderToString.ts
  • packages/redact/src/server/stream.ts
  • packages/redact/src/server/walk.ts
  • packages/redact/src/vite/index.ts
  • packages/redact/tests/basic.test.tsx
  • packages/redact/tests/callback-ref-commit-phase.test.tsx
  • packages/redact/tests/child-reorder.test.tsx
  • packages/redact/tests/compiler-runtime.test.tsx
  • packages/redact/tests/controlled-input.test.tsx
  • packages/redact/tests/create-root-clear-container.test.tsx
  • packages/redact/tests/document-hydration.test.tsx
  • packages/redact/tests/external-store-unmount.test.tsx
  • packages/redact/tests/feature-stubs.test.tsx
  • packages/redact/tests/floating-ui-pattern.test.tsx
  • packages/redact/tests/global.d.ts
  • packages/redact/tests/hooks.test.tsx
  • packages/redact/tests/hydration.test.tsx
  • packages/redact/tests/memo-state-rerender.test.tsx
  • packages/redact/tests/place-children-anchor.test.tsx
  • packages/redact/tests/portal-doctype.test.tsx
  • packages/redact/tests/public-exports.test.ts
  • packages/redact/tests/react-integration/attributes.test.tsx
  • packages/redact/tests/react-integration/basic.test.tsx
  • packages/redact/tests/react-integration/class-context-type.test.tsx
  • packages/redact/tests/react-integration/context.test.tsx
  • packages/redact/tests/react-integration/elements.test.tsx
  • packages/redact/tests/react-integration/form-controls.test.tsx
  • packages/redact/tests/react-integration/fragment.test.tsx
  • packages/redact/tests/react-integration/harness.tsx
  • packages/redact/tests/react-integration/hooks.test.tsx
  • packages/redact/tests/react-integration/modes.test.tsx
  • packages/redact/tests/react-integration/reconnecting.test.tsx
  • packages/redact/tests/react-integration/select.test.tsx
  • packages/redact/tests/react-integration/special-types.test.tsx
  • packages/redact/tests/rsc-hydration-adopt.test.tsx
  • packages/redact/tests/rsc-proxy-child.test.tsx
  • packages/redact/tests/rsc-raw-lazy.test.tsx
  • packages/redact/tests/security.test.tsx
  • packages/redact/tests/ssr-context.test.tsx
  • packages/redact/tests/ssr.test.tsx
  • packages/redact/tests/streaming-hydration.test.tsx
  • packages/redact/tests/suspense.test.tsx
  • packages/redact/tests/synthetic-event-shape.test.tsx
  • packages/redact/tests/tsconfig.json
  • packages/redact/tests/use-effect-coalesced-renders.test.tsx
  • packages/redact/tests/vitest.config.ts
  • packages/redact/tsconfig.json

Comment on lines +3 to +29
export 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 const enum FiberFlag {
None = 0,
Placement = 1 << 0,
Update = 1 << 1,
Deletion = 1 << 2,
Ref = 1 << 3,
Effect = 1 << 4,
LayoutEffect = 1 << 5,
ContentReset = 1 << 6,
DidCapture = 1 << 7,
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect tsconfig settings relevant to const enum compatibility for the redact package.
fd -a -t f 'tsconfig*.json' packages/redact || true
fd -a -t f 'tsconfig*.json' --max-depth 2 . | head -n 20

# Print the keys we care about from any tsconfig found in the redact package.
fd -t f 'tsconfig*.json' packages/redact -x sh -c '
  echo "==> {}";
  cat {} | tr -d "\r"
'

# Confirm cross-module usage of FiberTag / FiberFlag (any one of these crossing files implies the const enum risk applies).
rg -nP '\b(FiberTag|FiberFlag)\.' --type=ts -C1

Repository: nikilok/learn-tanstack-start

Length of output: 16396


Replace const enum declarations with regular enum or refactor to support isolatedModules.

The tsconfig.json sets "isolatedModules": true, which is incompatible with const enum when members are imported across module boundaries. FiberTag and FiberFlag are used extensively throughout the package (dom/reconcile.ts, root.ts, features/, etc.), and TypeScript cannot safely inline these values during transpilation under this configuration. Vite/esbuild will not handle the inlining correctly.

Options:

  1. Change const enum to regular enum (simplest, adds small runtime overhead)
  2. Convert to as const object literals (preserves const semantics without TypeScript enum behavior)
  3. Add "preserveConstEnums": true to tsconfig (requires real tsc build step, not esbuild alone)
  4. Disable "isolatedModules" if your build pipeline supports it
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/core/internal.ts` around lines 3 - 29, Replace the two
TypeScript const enum declarations (FiberTag and FiberFlag) so the code is
compatible with isolatedModules: either change `export const enum FiberTag` and
`export const enum FiberFlag` to regular `export enum FiberTag` and `export enum
FiberFlag`, or convert them into `export const` object literals with `as const`
to preserve values; update any imports/usages in code (references to FiberTag
and FiberFlag in reconcile/root/features code) accordingly so consumers access
the enum/object members at runtime without relying on const enum inlining.

Comment on lines +25 to +30
const events = q.splice(0)
for (const [type, target] of events) {
try {
const ev = createEvent(type)
target.dispatchEvent(ev)
} catch {}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Don't redispatch buffered DOM events as native events.

The browser already applied the original event before hydration. Dispatching a second click/submit/change here can replay native side effects, e.g. toggling a checkbox twice or submitting a form again after hydration. This needs a synthetic-handler replay path, or the queue must exclude events with native defaults.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/dom/event-replay.ts` around lines 25 - 30, The current
replay loop in event-replay.ts takes events from q, recreates them with
createEvent and calls target.dispatchEvent(ev), which will re-run native browser
defaults (e.g., toggling checkboxes or resubmitting forms); instead, stop
dispatching as native events and either (a) route to your synthetic-handler
replay path (invoke the component/library-level handler replay function for the
recorded [type,target] entry) or (b) filter out event types that cause native
side-effects before dispatching (e.g., "click","submit","change") and skip them;
locate the loop using q.splice(0), the createEvent call and target.dispatchEvent
and replace dispatching with a call into your synthetic replay API or an
explicit skip for native-defaulting event types.

Comment on lines +9 to +12
let frame: SSRFrame | null = null

export function beginSSR(identifierPrefix = ':R'): void {
frame = { idCounter: 0, identifierPrefix, contextStack: [] }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

The module-global SSR frame is unsafe for overlapping renders.

Every server hook reads from this singleton frame. As soon as two SSR/streaming requests overlap, one request can overwrite another request's context stack and useId counter, which risks cross-request data leakage in rendered HTML. This state needs to be isolated per render instead of living in one module slot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/dispatcher.ts` around lines 9 - 12, The
module-global frame variable (frame) used by beginSSR/SSRFrame is unsafe for
concurrent renders; instead create per-render state and avoid a shared module
slot: either (A) change beginSSR to return a new SSRFrame object (containing
idCounter, identifierPrefix, contextStack) and update all functions that
currently read the module-scoped frame to accept that SSRFrame as an explicit
parameter (including any useId-like helpers), or (B) use Node's
AsyncLocalStorage to store the SSRFrame per request and replace the
module-global frame reads/writes with AsyncLocalStorage.getStore()/run()
calls—ensure you remove the shared frame variable and update usages of frame,
beginSSR, SSRFrame, idCounter, identifierPrefix and contextStack accordingly so
each render has isolated state.

Comment thread packages/redact/src/server/escape.ts
Comment thread packages/redact/src/server/stream.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/redact/src/server/stream.ts (1)

13-21: 💤 Low value

progressiveChunkSize is declared but never used and contradicts the summary.

The AI summary states this option was removed from StreamOptions, but it's still declared on line 20 and never referenced anywhere in this file. Either drop it from the type to match the intended API surface, or wire it through to actually chunk progressively.

♻️ Proposed cleanup
 export interface StreamOptions {
   identifierPrefix?: string
   nonce?: string
   bootstrapScripts?: ReadonlyArray<string | { src: string; async?: boolean; nonce?: string }>
   bootstrapModules?: ReadonlyArray<string | { src: string; nonce?: string }>
   onError?: (error: unknown) => string | void
   signal?: AbortSignal
-  progressiveChunkSize?: number
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 13 - 21, The StreamOptions
interface currently declares progressiveChunkSize but it is unused; remove the
progressiveChunkSize property from the StreamOptions type to match the intended
API surface (or alternatively, if progressive chunking is desired, thread this
option into the stream creation/response code paths where chunks are
produced—e.g., whichever function consumes StreamOptions that handles chunking).
Update any related types/tests/docs that reference
StreamOptions.progressiveChunkSize so the API and implementation stay
consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/redact/src/server/stream.ts`:
- Around line 240-246: The abort handler currently only registers via
options.signal?.addEventListener and therefore ignores a signal that is already
aborted; update the logic in the stream setup (the block that touches
options.signal, state.closed, state.resolveClosed, and controller.close) to
synchronously check options.signal?.aborted before adding the listener and, if
true, immediately set state.closed = true, call state.resolveClosed(), and
attempt controller.close() (same error-safe behavior as the listener); keep the
existing addEventListener for future aborts and consider mirroring this
pre-abort check where renderToPipeableStream handles options.signal for
consistency.
- Around line 293-316: The microtask can call options.onShellReady after a
synchronous rejection because the rejection handler doesn't mark that the shell
failed; add a flag (e.g., shellErrored) and set it to true in the rejection
branch where !shellReady triggers options.onShellError (and ensure you still end
dest if needed), then update the queueMicrotask callback to return early if
aborted || finished || shellErrored before setting shellReady and calling
options.onShellReady; locate these changes around streamHtml(...).then(...) and
the queueMicrotask(...) block and update the variables shellReady/finished
checks accordingly.

---

Nitpick comments:
In `@packages/redact/src/server/stream.ts`:
- Around line 13-21: The StreamOptions interface currently declares
progressiveChunkSize but it is unused; remove the progressiveChunkSize property
from the StreamOptions type to match the intended API surface (or alternatively,
if progressive chunking is desired, thread this option into the stream
creation/response code paths where chunks are produced—e.g., whichever function
consumes StreamOptions that handles chunking). Update any related
types/tests/docs that reference StreamOptions.progressiveChunkSize so the API
and implementation stay consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e7afbe3-5a0f-45e0-87cf-8348c1b8bf87

📥 Commits

Reviewing files that changed from the base of the PR and between 36764b4 and 9d7bdc9.

📒 Files selected for processing (6)
  • apps/web/vite.config.ts
  • packages/redact/src/dom/dispatcher.ts
  • packages/redact/src/react/shared-internals.ts
  • packages/redact/src/server/escape.ts
  • packages/redact/src/server/stream.ts
  • packages/redact/tests/security.test.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/web/vite.config.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/redact/src/server/escape.ts
  • packages/redact/src/dom/dispatcher.ts
  • packages/redact/src/react/shared-internals.ts

Comment on lines +240 to +246
options.signal?.addEventListener('abort', () => {
state.closed = true
state.resolveClosed()
try {
controller.close()
} catch {}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Already-aborted signal is silently ignored.

addEventListener('abort', …) does nothing if options.signal.aborted is already true at call time, so a consumer that passes a pre-aborted signal will see the stream proceed (and potentially hang on a never-settling <Suspense> boundary) instead of closing immediately. Worth handling the pre-aborted case before subscribing — renderToPipeableStream similarly never reads options.signal at all, which may also be worth aligning.

🔧 Suggested fix
-      options.signal?.addEventListener('abort', () => {
-        state.closed = true
-        state.resolveClosed()
-        try {
-          controller.close()
-        } catch {}
-      })
+      const onAbort = () => {
+        state.closed = true
+        state.resolveClosed()
+        try {
+          controller.close()
+        } catch {}
+      }
+      if (options.signal?.aborted) {
+        onAbort()
+      } else {
+        options.signal?.addEventListener('abort', onAbort, { once: true })
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
options.signal?.addEventListener('abort', () => {
state.closed = true
state.resolveClosed()
try {
controller.close()
} catch {}
})
const onAbort = () => {
state.closed = true
state.resolveClosed()
try {
controller.close()
} catch {}
}
if (options.signal?.aborted) {
onAbort()
} else {
options.signal?.addEventListener('abort', onAbort, { once: true })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 240 - 246, The abort
handler currently only registers via options.signal?.addEventListener and
therefore ignores a signal that is already aborted; update the logic in the
stream setup (the block that touches options.signal, state.closed,
state.resolveClosed, and controller.close) to synchronously check
options.signal?.aborted before adding the listener and, if true, immediately set
state.closed = true, call state.resolveClosed(), and attempt controller.close()
(same error-safe behavior as the listener); keep the existing addEventListener
for future aborts and consider mirroring this pre-abort check where
renderToPipeableStream handles options.signal for consistency.

Comment on lines +293 to +316
streamHtml(children, emit, options, state).then(
() => {
finished = true
if (dest) dest.end()
options.onAllReady?.()
},
(err) => {
if (!shellReady) {
options.onShellError?.(err)
} else {
options.onError?.(err)
if (dest) dest.end()
}
},
)

// We call onShellReady once the first synchronous emit has landed.
// streamHtml above runs the shell synchronously before the first await in drain(),
// so we can schedule onShellReady right after the first microtask.
queueMicrotask(() => {
if (aborted || finished) return
shellReady = true
options.onShellReady?.()
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

onShellReady fires even after onShellError on synchronous shell failures.

When streamHtml rejects synchronously (e.g., walk throws during shell rendering), the microtask ordering ends up:

  1. .then(_, errh) is attached at line 293 → because the returned promise is already rejected, errh is queued first → calls options.onShellError with shellReady === false.
  2. queueMicrotask(...) at line 312 queues second → sees aborted === false and finished === false, sets shellReady = true, and calls options.onShellReady.

So consumers receive both onShellError and onShellReady for the same failed render, which violates the React-compatible contract that exactly one of those fires for the shell phase. The microtask needs to be gated on a flag mutated by the rejection path.

🔧 Suggested fix
   const buffers: string[] = []
   let dest: NodeJS.WritableStream | null = null
   let shellReady = false
+  let shellErrored = false
   let finished = false
   let aborted = false
@@
   // Kick off rendering
   streamHtml(children, emit, options, state).then(
     () => {
       finished = true
       if (dest) dest.end()
       options.onAllReady?.()
     },
     (err) => {
       if (!shellReady) {
+        shellErrored = true
         options.onShellError?.(err)
       } else {
         options.onError?.(err)
         if (dest) dest.end()
       }
     },
   )
@@
   queueMicrotask(() => {
-    if (aborted || finished) return
+    if (aborted || finished || shellErrored) return
     shellReady = true
     options.onShellReady?.()
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
streamHtml(children, emit, options, state).then(
() => {
finished = true
if (dest) dest.end()
options.onAllReady?.()
},
(err) => {
if (!shellReady) {
options.onShellError?.(err)
} else {
options.onError?.(err)
if (dest) dest.end()
}
},
)
// We call onShellReady once the first synchronous emit has landed.
// streamHtml above runs the shell synchronously before the first await in drain(),
// so we can schedule onShellReady right after the first microtask.
queueMicrotask(() => {
if (aborted || finished) return
shellReady = true
options.onShellReady?.()
})
streamHtml(children, emit, options, state).then(
() => {
finished = true
if (dest) dest.end()
options.onAllReady?.()
},
(err) => {
if (!shellReady) {
shellErrored = true
options.onShellError?.(err)
} else {
options.onError?.(err)
if (dest) dest.end()
}
},
)
// We call onShellReady once the first synchronous emit has landed.
// streamHtml above runs the shell synchronously before the first await in drain(),
// so we can schedule onShellReady right after the first microtask.
queueMicrotask(() => {
if (aborted || finished || shellErrored) return
shellReady = true
options.onShellReady?.()
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/redact/src/server/stream.ts` around lines 293 - 316, The microtask
can call options.onShellReady after a synchronous rejection because the
rejection handler doesn't mark that the shell failed; add a flag (e.g.,
shellErrored) and set it to true in the rejection branch where !shellReady
triggers options.onShellError (and ensure you still end dest if needed), then
update the queueMicrotask callback to return early if aborted || finished ||
shellErrored before setting shellReady and calling options.onShellReady; locate
these changes around streamHtml(...).then(...) and the queueMicrotask(...) block
and update the variables shellReady/finished checks accordingly.

@nikilok nikilok changed the title feat: migrate from react -> redact (thanks to Tanner Linsley and team) experimental: migrate from react -> redact (thanks to Tanner Linsley and team) May 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant