Skip to content

feat: support React 19 Activity API#289

Merged
guyca merged 26 commits intomasterfrom
test/lifecycle-bound-activity-pause-resilience
May 3, 2026
Merged

feat: support React 19 Activity API#289
guyca merged 26 commits intomasterfrom
test/lifecycle-bound-activity-pause-resilience

Conversation

@guyca
Copy link
Copy Markdown
Collaborator

@guyca guyca commented Apr 8, 2026

Adds inactiveBehavior option to @lifecycleBound to control what happens to a graph when its host component is hidden inside a React 19 <Activity> tree. Also fixes useObservable hooks so they re-apply when the component is unpaused.

  • New inactiveBehavior: 'unmount' | 'retain' option on @lifecycleBound (default: 'retain')
  • When retain, a sentinel DOM node detects whether the cleanup effect fired due to an Activity pause vs a real unmount — skipping graph teardown in the former case
  • When unmount, the graph is cleared on every unmount as before
  • useObservable hooks are now re-applied when a component resumes from an Activity pause (previously observers were not re-registered after unpause)
  • Bump react/react-dom devDependencies to 19.2.4 (Activity API; consumer peerDep range >=16.8.0 unchanged)
  • Document the new option in Graphs.mdx

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an inactiveBehavior: 'retain' option to @lifecycleBound() to prevent lifecycle-bound graphs from being evicted during React StrictMode effect replays / React 19 Activity pause-resume cycles.

Changes:

  • Extend @lifecycleBound() and ObjectGraph with inactiveBehavior metadata/accessor.
  • Wrap injected components with a platform-aware container/ref to distinguish “effect cleanup due to pause” vs “true unmount”.
  • Add jest-dom and new integration tests covering retained lifecycle-bound graphs.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
yarn.lock Locks new test dependency transitive graph (@testing-library/jest-dom).
packages/react-obsidian/package.json Adds @testing-library/jest-dom to devDependencies.
packages/react-obsidian/tsconfig.base.json Includes @testing-library/jest-dom types for TS matcher support.
packages/react-obsidian/jest.setup-after-env.js Registers jest-dom matchers in Jest environment.
packages/react-obsidian/src/decorators/LifecycleBound.ts Adds inactiveBehavior option/metadata to the decorator.
packages/react-obsidian/src/graph/ObjectGraph.ts Exposes inactiveBehavior getter on graph instances.
packages/react-obsidian/src/injectors/components/useGraph.ts Alters cleanup behavior to avoid clearing graphs on “inactive” cleanups.
packages/react-obsidian/src/injectors/components/ComponentInjector.tsx Introduces container ref + container wrapper around injected component output.
packages/react-obsidian/src/injectors/components/useGraphContainer.tsx Implements retain vs passthrough container selection + platform element selection.
packages/react-obsidian/src/utils/reactNativeAvailability.ts Adds runtime RN availability detection helper.
packages/react-obsidian/test/fixtures/RetainedLifecycleBoundGraph.ts Adds a lifecycle-bound graph fixture configured with inactiveBehavior: 'retain'.
packages/react-obsidian/test/integration/retainedLifecycleBoundGraph.test.tsx Adds integration tests for retained behavior under StrictMode.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +25
if (isCleanupCalledDueToActivityPause) return;
referenceCounter.release(graph, (g) => graphRegistry.clear(g));
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

useEffect cleanup currently returns early on an Activity pause (when containerRef.current is still set), which skips referenceCounter.release(...). In StrictMode/effect replay or Activity pause/resume, this will cause retain() calls to accumulate without matching releases, preventing graphs from ever reaching zero references and being cleared on a real unmount (memory/registry leak). Consider always calling referenceCounter.release(graph, ...), and only guard the graphRegistry.clear(g) callback based on whether this cleanup corresponds to a true unmount (e.g., check containerRef.current and conditionally clear inside the onReleased callback).

Suggested change
if (isCleanupCalledDueToActivityPause) return;
referenceCounter.release(graph, (g) => graphRegistry.clear(g));
referenceCounter.release(graph, (g) => {
if (!isCleanupCalledDueToActivityPause) {
graphRegistry.clear(g);
}
});

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +11
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isReactNativeAvailable() ? require('react-native').View : 'div';

const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>
));

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

require('react-native') is executed at module initialization time to pick Element. Since react-native is not a dependency/peerDependency of this package, many web/SSR bundlers will fail to resolve this string-literal require even if it would be caught at runtime, breaking consumers that don’t have react-native installed. Prefer deferring the require behind a runtime-only check that bundlers won’t try to resolve (or avoid requiring altogether and rely on navigator.product === 'ReactNative'), or move the RN-specific implementation behind a separate entry point.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isReactNativeAvailable() ? require('react-native').View : 'div';
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>
));
function getRetainContainerElement(): any {
if (!isReactNativeAvailable()) {
return 'div';
}
const runtimeRequire = Function('return require')() as NodeRequire;
return runtimeRequire('react-native').View;
}
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => {
const Element = getRetainContainerElement();
return (
<Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>
);
});

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +9
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isReactNativeAvailable() ? require('react-native').View : 'div';

const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

RetainContainer unconditionally applies style={{ display: 'contents' }} to both the web div and React Native View. display: 'contents' is not a supported RN display value (RN typically supports flex/none), so this can cause warnings or layout issues on native. Consider applying display: 'contents' only for the web element and using a plain <View> (or no style) for RN.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isReactNativeAvailable() ? require('react-native').View : 'div';
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>
const isReactNative = isReactNativeAvailable();
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isReactNative ? require('react-native').View : 'div';
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
isReactNative
? <Element ref={ref}>{props.children}</Element>
: <Element ref={ref} style={{ display: 'contents' }}>{props.children}</Element>

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +14
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
cache = typeof document === 'undefined' && !!require('react-native');
} catch {
cache = false;
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

isReactNativeAvailable() falls back to require('react-native') when document is undefined. This can (a) produce false positives in non-RN Node/SSR environments where react-native happens to be installed (causing RN components to be chosen for web rendering), and (b) still break bundlers due to the static string-literal require. Consider basing detection solely on runtime signals (e.g., navigator.product === 'ReactNative') and/or using a require pattern that won’t be statically resolved by bundlers.

Suggested change
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
cache = typeof document === 'undefined' && !!require('react-native');
} catch {
cache = false;
}
cache = false;

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +22
it('retains the graph in the registry when effects are cleaned up without unmounting', () => {
render(
<React.StrictMode>
<PausedComponent />
</React.StrictMode>,
);

expect(screen.getByTestId('component')).toBeInTheDocument();
expect(() => Obsidian.obtain(RetainedLifecycleBoundGraph)).not.toThrow();
});
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The new tests cover StrictMode/effect cleanup scenarios, but there’s no assertion that a graph with inactiveBehavior: 'retain' is still cleared on a real unmount. Adding an unmount test here would help catch reference-count leaks and ensure retained graphs don’t persist forever after the component truly unmounts.

Copilot uses AI. Check for mistakes.
@guyca guyca marked this pull request as draft April 8, 2026 12:47
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +6 to +12
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isRN ? require('react-native').View : 'div';

const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={isRN ? undefined : { display: 'contents' }}>{props.children}</Element>
));

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

require('react-native') is used here even though react-native isn’t declared as a dependency/peerDependency for this package. Many web bundlers (webpack/rollup/vite) will still try to statically resolve require('react-native') during bundling even when it’s behind a runtime conditional, which can break web consumers that don’t have react-native installed. Consider avoiding a top-level require('react-native') (e.g., defer the require behind a non-statically-analyzable dynamic require, or ship a React Native–specific entrypoint / optional peerDependency) so non-RN builds don’t fail at build time.

Suggested change
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
const Element: any = isRN ? require('react-native').View : 'div';
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<Element ref={ref} style={isRN ? undefined : { display: 'contents' }}>{props.children}</Element>
));
let retainElement: any;
function getRetainElement() {
if (!isRN) {
return 'div';
}
if (!retainElement) {
const dynamicRequire = (globalThis as typeof globalThis & { require?: NodeRequire })['require'];
retainElement = dynamicRequire ? dynamicRequire('react-native').View : 'div';
}
return retainElement;
}
const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => {
const Element = getRetainElement();
return (
<Element ref={ref} style={isRN ? undefined : { display: 'contents' }}>{props.children}</Element>
);
});

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3 to +8
// Indirection makes require non-statically-analyzable so bundlers won't try to resolve the module name
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-eval
const dynamicRequireFn: ((name: string) => any) | undefined = typeof require !== 'undefined'
? eval('require')
: undefined;

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

eval('require') will throw in web bundles that run under a strict Content Security Policy (CSP) that disallows eval, and typeof require !== 'undefined' is often true in webpack/rollup runtime shims. That means simply importing this package could crash in some production web environments. Consider guarding the eval with a try/catch and falling back to undefined (treating the package as unavailable), or using a CSP-safe non-statically-analyzable require mechanism supported by common bundlers (e.g., webpack’s __non_webpack_require__ when present).

Suggested change
// Indirection makes require non-statically-analyzable so bundlers won't try to resolve the module name
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-eval
const dynamicRequireFn: ((name: string) => any) | undefined = typeof require !== 'undefined'
? eval('require')
: undefined;
declare const __non_webpack_require__: ((name: string) => any) | undefined;
// Indirection makes require non-statically-analyzable so bundlers won't try to resolve the module name.
// Prefer bundler-provided non-webpack require when available, and gracefully fall back to
// undefined in CSP-restricted environments where eval is blocked.
function getDynamicRequireFn(): ((name: string) => any) | undefined {
if (typeof __non_webpack_require__ !== 'undefined') {
return __non_webpack_require__;
}
if (typeof require === 'undefined') {
return undefined;
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-eval
return eval('require');
} catch {
return undefined;
}
}
const dynamicRequireFn: ((name: string) => any) | undefined = getDynamicRequireFn();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +19 to +27
export function isPackageAvailable(name: string): boolean {
if (cache.has(name)) return cache.get(name)!;
try {
dynamicRequireFn?.(name);
cache.set(name, true);
} catch {
cache.set(name, false);
}
return cache.get(name)!;
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

isPackageAvailable will incorrectly return true when require is unavailable (e.g. ESM/browser builds). Because dynamicRequireFn is undefined, dynamicRequireFn?.(name) becomes a no-op and the code sets the cache to true, which can cause downstream require(...) calls to run even though require doesn't exist. Fix by explicitly returning false when dynamicRequireFn is undefined (or throwing inside the try when it's missing) before setting the cache to true.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1 to 5
import { isPackageAvailable } from './packageAvailability';

export function isReactAvailable(): boolean {
if (reactAvailableCache !== undefined) {
return reactAvailableCache;
}

try {
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
require('react');
reactAvailableCache = true;
} catch {
reactAvailableCache = false;
}

return reactAvailableCache;
return isPackageAvailable('react');
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

isReactAvailable() now relies on isPackageAvailable(), which in turn depends on eval('require'). In environments where require exists (bundled CJS) but eval is blocked (common strict CSP without unsafe-eval), this will report React as unavailable and cause React-dependent exports (e.g., injectComponent) to be stubbed even though React is present. Consider keeping the previous try { require('react') } check for React specifically (since React is a hard runtime dependency for these features anyway) and reserving the eval('require') indirection for truly optional packages like react-native.

Copilot uses AI. Check for mistakes.
@guyca guyca marked this pull request as ready for review April 8, 2026 18:54
@guyca guyca marked this pull request as draft April 8, 2026 18:54
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


const RetainContainer = forwardRef<any, PropsWithChildren>((props, ref) => (
<>
<Sentinel ref={ref} collapsable={false} style={sentinelStyle} />
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

When Sentinel is 'div' (web), passing collapsable={false} results in an unknown DOM attribute being rendered and can trigger React warnings. Make this prop conditional so it’s only provided for the React Native View sentinel.

Suggested change
<Sentinel ref={ref} collapsable={false} style={sentinelStyle} />
<Sentinel ref={ref} style={sentinelStyle} {...(isRN ? { collapsable: false } : {})} />

Copilot uses AI. Check for mistakes.
Comment on lines 20 to +28
useEffect(() => {
referenceCounter.retain(graph);
return () => referenceCounter.release(graph, (g) => graphRegistry.clear(g));
return () => {
const isCleanupCalledDueToActivityPause = containerRef?.current;
referenceCounter.release(graph, (g) => {
if (!isCleanupCalledDueToActivityPause) {
graphRegistry.clear(g);
}
});
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

inactiveBehavior: 'retain' only takes effect when containerRef is provided and kept attached; callers like the hook injector (src/injectors/hooks/HookInjector.ts) call useGraph() without a ref, so retained lifecycle-bound graphs used via injectHook will still be cleared on effect cleanup. Consider extending the retention mechanism to hook injection (or explicitly restricting/guarding the behavior in useGraph() and documenting that retain is component-injection-only).

Copilot uses AI. Check for mistakes.
@guyca guyca changed the title feat: add inactiveBehavior option to @lifecycleBound for Activity API resilience docs: document inactiveBehavior; refactor sentinel to sibling Apr 9, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +195 to +198
| Value | Behavior |
|-------|----------|
| `'unmount'` (default) | Graph cleared on every `useEffect` cleanup |
| `'retain'` | Graph retained during transient cleanups; cleared only on real unmount |
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The markdown table under "Inactive behavior" has an extra leading | on each row (e.g. || Value | Behavior |), which prevents it from rendering as a proper table in most markdown/MDX renderers. Use a single leading pipe per row (| Value | Behavior |, etc.).

Copilot uses AI. Check for mistakes.
guyca added 3 commits April 10, 2026 11:27
…y API

- Upgrade react/react-dom to 19.2.4, @types/react/react-dom to ^19.0.0,
  @testing-library/react to 16.x (React 19-only), add @testing-library/dom ^10.0.0

- Fix useGraph sentinel check: capture ref.current at effect setup time,
  then use sentinel.isConnected at cleanup to distinguish Activity hide
  (DOM preserved, isConnected=true → retain) from real unmount
  (DOM removed, isConnected=false → clear). React 19 clears ref.current
  before cleanup runs, making the old containerRef?.current check always null.

- Add test: retains graph when screen hidden by React Activity component
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +21 to +29
const sentinel = containerRef?.current;
referenceCounter.retain(graph);
return () => referenceCounter.release(graph, (g) => graphRegistry.clear(g));
return () => {
const isCleanupCalledDueToActivityPause = graph.inactiveBehavior === 'retain' && sentinel?.isConnected;
referenceCounter.release(graph, (g) => {
if (!isCleanupCalledDueToActivityPause) {
graphRegistry.clear(g);
}
});
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

sentinel?.isConnected is a DOM-only API; on React Native the View ref won’t have isConnected, so inactiveBehavior: 'retain' will behave like 'unmount' and the graph will still be cleared during activity pauses/hidden states. Consider using a cross-platform check (e.g., consult containerRef.current at cleanup time, or gate isConnected usage to web and use a RN-appropriate mounted/attached signal) so retain works on both platforms.

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +183
#### Inactive behavior
By default, lifecycle-bound graphs are retained during transient `useEffect` cleanups — such as React Native activity pauses or React StrictMode remounts — and only cleared when the component is actually removed from the tree. This prevents the graph from being destroyed and recreated unnecessarily.

Setting `inactiveBehavior` to `'unmount'` reverts to eager cleanup, where the graph is cleared on every `useEffect` cleanup regardless of whether the component has truly unmounted.

```ts title="A lifecycle-bound graph with eager cleanup"
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

This section states that 'retain' is the default inactive behavior, but the implementation currently defaults to 'unmount' (see lifecycleBound metadata default and ObjectGraph.inactiveBehavior fallback). Please align the docs with the actual default, or change the code defaults if the intended default is 'retain'.

Copilot uses AI. Check for mistakes.
return (constructor: any) => {
Reflect.defineMetadata('isLifecycleBound', true, constructor);
Reflect.defineMetadata('lifecycleScope', options?.scope ?? 'feature', constructor);
Reflect.defineMetadata('inactiveBehavior', options?.inactiveBehavior ?? 'unmount', constructor);
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

inactiveBehavior metadata is defaulted to 'unmount' here, but the newly added docs describe 'retain' as the default. If the intended default is retain, this should be updated (and kept consistent with ObjectGraph.inactiveBehavior’s fallback). If unmount is the intended default, the docs/example table should be adjusted accordingly.

Suggested change
Reflect.defineMetadata('inactiveBehavior', options?.inactiveBehavior ?? 'unmount', constructor);
Reflect.defineMetadata('inactiveBehavior', options?.inactiveBehavior ?? 'retain', constructor);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 19 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

private propertyRetriever = new PropertyRetriever(this);

get inactiveBehavior(): 'unmount' | 'retain' {
return Reflect.getMetadata('inactiveBehavior', this.constructor) ?? 'unmount';
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

inactiveBehavior falls back to 'unmount' when no metadata is present, but @lifecycleBound now defaults inactiveBehavior to 'retain' (and the docs describe 'retain' as the default). This can break backwards compatibility for graphs compiled/packaged with an older @lifecycleBound implementation that didn’t write the metadata. Consider defaulting to 'retain' when isLifecycleBound metadata is true, or aligning the fallback with the documented default.

Suggested change
return Reflect.getMetadata('inactiveBehavior', this.constructor) ?? 'unmount';
const inactiveBehavior = Reflect.getMetadata('inactiveBehavior', this.constructor);
if (inactiveBehavior != null) {
return inactiveBehavior;
}
if (Reflect.getMetadata('isLifecycleBound', this.constructor) === true) {
return 'retain';
}
return 'unmount';

Copilot uses AI. Check for mistakes.
Comment on lines 41 to +71
@@ -65,8 +67,8 @@
"jest-mock-extended": "^3.0.7",
"jest-when": "^3.7.0",
"lodash": "^4.17.21",
"react": "18.2.x",
"react-dom": "18.2.x",
"react": "19.2.4",
"react-dom": "19.2.4",
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

This PR description focuses on inactiveBehavior docs and the sentinel refactor, but this package also upgrades React/ReactDOM to 19.2.4 and Testing Library to v16 (+ new jest-dom/dom deps). That’s a major, potentially breaking/toolchain-affecting change; please either call it out explicitly in the PR description (and rationale), or split the dependency upgrades into a separate PR to keep scope/risk clear.

Copilot uses AI. Check for mistakes.
@guyca guyca changed the title docs: document inactiveBehavior; refactor sentinel to sibling feat: support React 19 Activity API Apr 10, 2026
@guyca
Copy link
Copy Markdown
Collaborator Author

guyca commented Apr 23, 2026

@copilot resolve the merge conflicts in this pull request

Co-authored-by: guyca <12352397+guyca@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 23, 2026

@copilot resolve the merge conflicts in this pull request

Resolved the merge conflicts in commit 7a94f85. The only conflict was in yarn.lock — resolved by accepting our version and regenerating it with yarn install to incorporate both our React 19 packages and master's updates (docusaurus 3.9.2, ts-jest 29.4.9).

@guyca guyca marked this pull request as ready for review May 3, 2026 06:51
@guyca guyca merged commit 8c720f7 into master May 3, 2026
3 checks passed
@guyca guyca deleted the test/lifecycle-bound-activity-pause-resilience branch May 3, 2026 06:51
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.

3 participants