Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thirty-buttons-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lynx-js/react': patch
---

Fix a bug where React throws `CtxNotFound` error when lazy bundle resolves after unmount.
133 changes: 132 additions & 1 deletion packages/react/runtime/__test__/lynx/suspense.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// LICENSE file in the root directory of this source tree.

import { render, Component } from 'preact';
import { Suspense } from '../../src/index';
import { createElement } from 'preact/compat';
import { Suspense, lazy, useState } from '../../src/index';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
Expand All @@ -16,6 +17,7 @@ import { elementTree } from '../utils/nativeMethod';
import { backgroundSnapshotInstanceManager } from '../../src/snapshot';
import { prettyFormatSnapshotPatch } from '../../src/debug/formatPatch';
import { createSuspender } from '../createSuspender';
import { BackgroundSnapshotInstance } from '../../src/backgroundSnapshot';

beforeAll(() => {
setupPage(__CreatePage('0', 0));
Expand Down Expand Up @@ -1500,4 +1502,133 @@ describe('suspense', () => {
vi.runAllTimers();
}
});

it('should not update torn-down parent when lazy resolves after unmount', async () => {
// Repro steps:
// 1) Mount a Suspense boundary with a lazy child so it suspends and shows fallback.
// 2) Unmount the entire subtree and apply the unmount patch on the main thread.
// 3) Resolve the lazy child after unmount and trigger a state update so background produces a patch.
// 4) Apply that late patch on the main thread and assert ctx-not-found is reported.
const deferred = Promise.withResolvers();
const LazyChild = lazy(() => deferred.promise);

let setShow;
let setColor;

function App() {
const [show, _setShow] = useState(true);
const [color, _setColor] = useState('red');
setShow = _setShow;
setColor = _setColor;

return show
? (
<view color={color}>
<Suspense fallback='loading'>
<view id='suspense-child'>
<LazyChild />
</view>
</Suspense>
</view>
)
: null;
}

{
globalEnvManager.switchToMainThread();
__root.__jsx = createElement(App, null);
renderPage();
}

{
globalEnvManager.switchToBackground();
render(createElement(App, null), __root);
}

{
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1);
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];

globalEnvManager.switchToMainThread();
globalThis[rLynxChange[0]](rLynxChange[1]);
expect(__root.__element_root).toMatchInlineSnapshot(`
<page
cssId="default-entry-from-native:0"
>
<view
color="red"
>
<wrapper>
<raw-text
text="loading"
/>
</wrapper>
</view>
</page>
`);

globalEnvManager.switchToBackground();
rLynxChange[2]();
}

lynx.getNativeApp().callLepusMethod.mockClear();

{
globalEnvManager.switchToBackground();
setShow(false);
await Promise.resolve().then(() => {});

expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];

expect(prettyFormatSnapshotPatch(JSON.parse(rLynxChange[1].data).patchList[0].snapshotPatch))
.toMatchInlineSnapshot(`
[
{
"childId": -4,
"op": "RemoveChild",
"parentId": -1,
},
]
`);

globalEnvManager.switchToMainThread();
globalThis[rLynxChange[0]](rLynxChange[1]);
expect(__root.__element_root).toMatchInlineSnapshot(`
<page
cssId="default-entry-from-native:0"
/>
`);
}

lynx.getNativeApp().callLepusMethod.mockClear();

{
globalEnvManager.switchToBackground();
deferred.resolve({ default: () => <view id='lazy' /> });
setColor('green');
await Promise.resolve().then(() => {});

expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];

expect(prettyFormatSnapshotPatch(JSON.parse(rLynxChange[1].data).patchList[0].snapshotPatch))
.toMatchInlineSnapshot(`[]`);

globalEnvManager.switchToMainThread();

// Apply the late patch on the main thread.
globalThis[rLynxChange[0]](rLynxChange[1]);
expect(__root.__element_root).toMatchInlineSnapshot(`
<page
cssId="default-entry-from-native:0"
/>
`);

// snapshotPatchApply should emit a ctx-not-found event back to BG,
// which is converted into a lynx.reportError in error.ts.
expect(lynx.getJSContext().dispatchEvent.mock.calls).toMatchInlineSnapshot(`[]`);
}
});
});
15 changes: 14 additions & 1 deletion packages/react/runtime/src/backgroundSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ export class BackgroundSnapshotInstance {
private __nextSibling: BackgroundSnapshotInstance | null = null;
private __removed_from_tree?: boolean;

private get isDetached(): boolean {
let node: BackgroundSnapshotInstance | null = this;
while (node) {
if (node.__removed_from_tree) {
return true;
}
node = node.__parent;
}
return false;
}

get parentNode(): BackgroundSnapshotInstance | null {
return this.__parent;
}
Expand All @@ -85,7 +96,9 @@ export class BackgroundSnapshotInstance {

// This will be called in `lazy`/`Suspense`.
appendChild(child: BackgroundSnapshotInstance): void {
return this.insertBefore(child);
if (!this.isDetached) {
return this.insertBefore(child);
}
}

insertBefore(
Expand Down
Loading