Skip to content

Commit 4302939

Browse files
committed
Do not render disconnected nodes.
1 parent f23f403 commit 4302939

File tree

8 files changed

+143
-64
lines changed

8 files changed

+143
-64
lines changed

.vscode/launch.json

+2-4
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
"configurations": [
33
{
44
"args": [
5-
"-r",
6-
"esm",
75
"--timeout",
86
"999999",
97
"--colors",
10-
"${workspaceFolder}/test/dist/test"
8+
"${workspaceFolder}/dist/test/test"
119
],
1210
"internalConsoleOptions": "openOnSessionStart",
1311
"name": "Mocha Tests",
@@ -17,7 +15,7 @@
1715
"<node_internals>/**"
1816
],
1917
"type": "node",
20-
"preLaunchTask": "build-all"
18+
"preLaunchTask": "build-dev"
2119
}
2220
]
2321
}

.vscode/tasks.json

+5-21
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,10 @@
11
{
2-
"version": "2.0.0",
3-
"tasks": [
2+
"version": "2.0.0",
3+
"tasks": [
44
{
55
"type": "npm",
6-
"script": "build",
7-
"group": "build",
8-
"problemMatcher": [],
9-
"label": "npm: build",
10-
"detail": "./build.sh"
11-
},
12-
{
13-
"type": "npm",
14-
"script": "build-test",
15-
"group": "build",
16-
"problemMatcher": [],
17-
"label": "npm: build-test",
18-
"detail": "(cd test && ./build.sh)",
19-
"dependsOn": ["npm: build"]
20-
},
21-
{
22-
"label": "build-all",
23-
"dependsOn": ["npm: build", "npm: build-test"]
6+
"label": "build-dev",
7+
"script": "build-dev"
248
}
259
]
26-
}
10+
}

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "forgo-state",
3-
"version": "1.0.33",
3+
"version": "1.0.34",
44
"type": "module",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",
@@ -26,6 +26,7 @@
2626
"scripts": {
2727
"clean": "rimraf ./dist",
2828
"build": "npm run clean && mkdir -p dist && npx tsc",
29+
"build-dev": "npx tsc",
2930
"test": "mocha dist/test/test.js"
3031
},
3132
"license": "MIT"

src/index.ts

+50-37
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
/*
2+
How it works:
3+
4+
- Users create JS proxies using the defineState() function.
5+
- They bind this state (the proxy object) to various components via bindToStates() and bindToStateProps() functions.
6+
- Since the proxy let's us capture changes to itself, we trigger component rerenders (on bound components) when that happens.
7+
*/
8+
19
import {
210
ForgoRenderArgs,
311
ForgoComponent,
@@ -8,58 +16,63 @@ import {
816
NodeAttachedState,
917
} from "forgo";
1018

11-
export type ForgoProxyState = {};
12-
13-
type StateMapEntry<TProps extends ForgoElementProps> = {
19+
type StateBoundComponentInfo<TProps extends ForgoElementProps> = {
1420
component: ForgoComponent<TProps>;
1521
args: ForgoRenderArgs;
1622
};
1723

18-
type RerenderOnAnyChange<TState, TProps extends ForgoElementProps> = {
24+
type PropertyBoundComponentInfo<TState, TProps extends ForgoElementProps> = {
1925
propGetter: (state: TState) => any[];
20-
} & StateMapEntry<TProps>;
26+
} & StateBoundComponentInfo<TProps>;
2127

22-
const stateMap: Map<any, StateMapEntry<any>[]> = new Map();
28+
const stateToComponentsMap: Map<any, StateBoundComponentInfo<any>[]> =
29+
new Map();
2330

2431
export function defineState<TState extends Record<string, any>>(
2532
state: TState
2633
): TState {
2734
const handlers = {
2835
set(target: TState, prop: string & keyof TState, value: any) {
29-
const entries = stateMap.get(proxy);
36+
const entries = stateToComponentsMap.get(proxy);
3037

3138
// if bound to the state directly, add for updation on any state change.
32-
const argsForUncheckedUpdation: ForgoRenderArgs[] = entries
39+
const stateBoundComponentArgs: ForgoRenderArgs[] = entries
3340
? entries
34-
.filter((x) => !(x as RerenderOnAnyChange<TState, any>).propGetter)
41+
.filter(
42+
(x) => !(x as PropertyBoundComponentInfo<TState, any>).propGetter
43+
)
3544
.map((x) => x.args)
3645
: [];
3746

38-
// Get the props before update
39-
let propsToCompare = entries
40-
? entries
41-
.filter((x) => (x as RerenderOnAnyChange<TState, any>).propGetter)
42-
.map((x) => ({
43-
args: x.args,
44-
props: (x as RerenderOnAnyChange<TState, any>).propGetter(target),
45-
}))
47+
const propBoundComponents = entries
48+
? entries.filter(
49+
(x) => (x as PropertyBoundComponentInfo<TState, any>).propGetter
50+
)
4651
: [];
4752

53+
// Get the props before update
54+
let propBoundComponentArgs = propBoundComponents.map((x) => ({
55+
args: x.args,
56+
props: (x as PropertyBoundComponentInfo<TState, any>).propGetter(
57+
target
58+
),
59+
}));
60+
4861
target[prop] = value;
4962

5063
// Get the props after update
51-
let updatedProps = entries
52-
? entries
53-
.filter((x) => (x as RerenderOnAnyChange<TState, any>).propGetter)
54-
.map((x) => ({
55-
args: x.args,
56-
props: (x as RerenderOnAnyChange<TState, any>).propGetter(target),
57-
}))
58-
: [];
59-
60-
// concat state based updates and props based updates
61-
const argsListToUpdate = argsForUncheckedUpdation.concat(
62-
propsToCompare
64+
let updatedProps = propBoundComponents.map((x) => ({
65+
args: x.args,
66+
props: (x as PropertyBoundComponentInfo<TState, any>).propGetter(
67+
target
68+
),
69+
}));
70+
71+
// State bound components (a) need to be rerendered anyway.
72+
// Prop bound components (b) are rendendered if changed.
73+
// So concat (a) and (b)
74+
const argsListToUpdate = stateBoundComponentArgs.concat(
75+
propBoundComponentArgs
6376
.filter((oldProp, i) =>
6477
oldProp.props.some((p, j) => p !== updatedProps[i].props[j])
6578
)
@@ -84,7 +97,7 @@ export function defineState<TState extends Record<string, any>>(
8497
});
8598

8699
// If a parent component is already rerendering,
87-
// don't queue the child rerender.
100+
// don't queue the child rerender.
88101
const componentsToUpdate = componentStatesAndArgs.filter((item) => {
89102
const [componentState, args] = item;
90103

@@ -141,7 +154,7 @@ let argsToRenderInTheNextCycle: ForgoRenderArgs[] = [];
141154
function doRender() {
142155
if (argsToRenderInTheNextCycle.length) {
143156
for (const args of argsToRenderInTheNextCycle) {
144-
if (args.element.node) {
157+
if (args.element.node && args.element.node.isConnected) {
145158
rerender(args.element);
146159
}
147160
}
@@ -167,23 +180,23 @@ export function bindToStateProps<TState, TProps extends ForgoElementProps>(
167180
...component,
168181
mount(props: TProps, args: ForgoRenderArgs) {
169182
for (const [state, propGetter] of stateBindings) {
170-
let entries = stateMap.get(state);
183+
let entries = stateToComponentsMap.get(state);
171184

172185
if (!entries) {
173186
entries = [];
174-
stateMap.set(state, entries);
187+
stateToComponentsMap.set(state, entries);
175188
}
176189

177190
if (propGetter) {
178-
const newEntry: RerenderOnAnyChange<TState, TProps> = {
191+
const newEntry: PropertyBoundComponentInfo<TState, TProps> = {
179192
component: wrappedComponent,
180193
propGetter,
181194
args,
182195
};
183196

184197
entries.push(newEntry);
185198
} else {
186-
const newEntry: StateMapEntry<TProps> = {
199+
const newEntry: StateBoundComponentInfo<TProps> = {
187200
component: wrappedComponent,
188201
args,
189202
};
@@ -198,10 +211,10 @@ export function bindToStateProps<TState, TProps extends ForgoElementProps>(
198211
},
199212
unmount(props: TProps, args: ForgoRenderArgs) {
200213
for (const [state] of stateBindings) {
201-
let entry = stateMap.get(state);
214+
let entry = stateToComponentsMap.get(state);
202215

203216
if (entry) {
204-
stateMap.set(
217+
stateToComponentsMap.set(
205218
state,
206219
entry.filter((x) => x.component !== wrappedComponent)
207220
);

src/test/descendantMustNotRerender/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { run } from "./script.js";
44
import should from "should";
55

66
export default function () {
7-
it("skips rerendering descendant if parent is rerendering", async () => {
7+
it("must not rerender descendent", async () => {
88
const dom = new JSDOM(htmlFile(), {
99
runScripts: "outside-only",
1010
resources: "usable",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { JSDOM } from "jsdom";
2+
import htmlFile from "../htmlFile.js";
3+
import { addNewMessage, renderAgain, run } from "./script.js";
4+
5+
export default function () {
6+
it("doesn't render disconnected nodes", async () => {
7+
const dom = new JSDOM(htmlFile(), {
8+
runScripts: "outside-only",
9+
resources: "usable",
10+
});
11+
const window = dom.window;
12+
13+
run(dom);
14+
15+
await new Promise<void>((resolve) => {
16+
window.addEventListener("load", () => {
17+
resolve();
18+
});
19+
});
20+
21+
addNewMessage();
22+
renderAgain();
23+
24+
window.document.body.innerHTML.trim().should.containEql(`<div id="root"></div>`)
25+
});
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as forgo from "forgo";
2+
import { DOMWindow, JSDOM } from "jsdom";
3+
import { mount, ForgoRenderArgs, setCustomEnv } from "forgo";
4+
import { bindToStates, defineState } from "../../index.js";
5+
6+
let window: DOMWindow;
7+
let document: Document;
8+
9+
type State = {
10+
messageCount: number;
11+
};
12+
13+
const state: State = defineState({
14+
messageCount: 0,
15+
});
16+
17+
export function addNewMessage() {
18+
state.messageCount++;
19+
}
20+
21+
let counter = 0;
22+
23+
let renderArgs: ForgoRenderArgs;
24+
export function renderAgain() {
25+
counter++;
26+
renderArgs.update();
27+
}
28+
29+
function Parent() {
30+
return {
31+
render(props: {}, args: ForgoRenderArgs) {
32+
renderArgs = args;
33+
return counter === 0 ? <MessageBox /> : null;
34+
},
35+
};
36+
}
37+
38+
function MessageBox() {
39+
const component = {
40+
render(props: any, args: ForgoRenderArgs) {
41+
return <p>You have {state.messageCount} messages.</p>;
42+
},
43+
};
44+
return bindToStates([state], component);
45+
}
46+
47+
export function run(dom: JSDOM) {
48+
window = dom.window;
49+
document = window.document;
50+
setCustomEnv({ window, document });
51+
52+
window.addEventListener("load", () => {
53+
mount(<Parent />, document.getElementById("root"));
54+
});
55+
}

src/test/test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import bindToStateProps from "./bindToStateProps/index.js";
44
import descendantMustNotRerender from "./descendantMustNotRerender/index.js";
55
import batchesUpdates from "./batchesUpdates/index.js";
66
import fragments from "./fragments/index.js";
7+
import dontRenderDisconnectedNodes from "./dontRenderDisconnectedNodes/index.js";
78

89
defineState();
910
bindToStates();
1011
bindToStateProps();
1112
descendantMustNotRerender();
1213
batchesUpdates();
1314
fragments();
15+
dontRenderDisconnectedNodes();

0 commit comments

Comments
 (0)