Skip to content

Commit 81a4578

Browse files
committed
feature: suspense api
1 parent 2a63eaa commit 81a4578

14 files changed

+351
-38
lines changed

plugins/utils.test.ts

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,43 @@
11
import { expect, test, describe } from 'vitest';
2-
import { toSafeJSPath } from './utils';
2+
import { toSafeJSPath, escapeString } from './utils';
33

44
const f = (str: string) => toSafeJSPath(str);
5+
const e = (str: any) => escapeString(str as string);
6+
7+
8+
describe('escapeString', () => {
9+
test('works for classic case', () => {
10+
expect(e('this.foo.bar.baz')).toEqual(`"this.foo.bar.baz"`);
11+
});
12+
test('works for string with quotes', () => {
13+
expect(e('this.foo.bar.baz"')).toEqual(`"this.foo.bar.baz\\""`);
14+
});
15+
test('works for string with double quotes', () => {
16+
expect(e('"this.foo.bar.baz"')).toEqual(`"this.foo.bar.baz"`);
17+
});
18+
test('works for string with double quotes #2', () => {
19+
expect(e('this.foo.bar"baz')).toEqual(`"this.foo.bar\\"baz"`);
20+
});
21+
test('works for strings with template literals', () => {
22+
expect(e('this.foo.bar`baz`')).toEqual(`"this.foo.bar\`baz\`"`);
23+
});
24+
test('works for strings like numbers', () => {
25+
expect(e('123')).toEqual(`"123"`);
26+
});
27+
test('works for strings like numbers #2', () => {
28+
expect(e('123.123')).toEqual(`"123.123"`);
29+
});
30+
test('works for strings like numbers #3', () => {
31+
expect(e('123.123.123')).toEqual(`"123.123.123"`);
32+
});
33+
test('throw error if input is not a string', () => {
34+
expect(() => e(123)).toThrow('Not a string')
35+
});
36+
test('skip already escaped strings', () => {
37+
expect(e('"this.foo.bar.baz"')).toEqual(`"this.foo.bar.baz"`);
38+
});
39+
});
40+
541

642
describe('toSafeJSPath', () => {
743
test('works for classic case', () => {

plugins/utils.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,16 @@ export function resetContextCounter() {
4545
}
4646

4747
export function escapeString(str: string) {
48-
const lines = str.split('\n');
49-
if (lines.length === 1) {
50-
if (str.startsWith("'")) {
51-
return str;
52-
} else if (str.startsWith('"')) {
53-
return str;
54-
} else {
55-
return `"${str}"`;
48+
if (typeof str !== 'string') {
49+
throw new Error('Not a string');
50+
}
51+
try {
52+
if (typeof JSON.parse(str) !== 'string') {
53+
return JSON.stringify(str);
5654
}
57-
} else {
58-
return `\`${str}\``;
55+
return JSON.stringify(JSON.parse(str));
56+
} catch (e) {
57+
return JSON.stringify(str);
5958
}
6059
}
6160

src/components/Application.gts

+4-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
runDestructors,
44
Component,
55
tracked,
6+
getRoot,
67
} from '@lifeart/gxt';
78
import { PageOne } from './pages/PageOne.gts';
89
import { PageTwo } from './pages/PageTwo.gts';
@@ -11,8 +12,10 @@ import { Benchmark } from './pages/Benchmark.gts';
1112
import { NestedRouter } from './pages/NestedRouter.gts';
1213
import { router } from './../services/router';
1314

15+
let version = 0;
1416
export class Application extends Component {
1517
router = router;
18+
version = version++;
1619
@tracked
1720
now = Date.now();
1821
rootNode!: HTMLElement;
@@ -23,7 +26,7 @@ export class Application extends Component {
2326
benchmark: Benchmark,
2427
};
2528
async destroy() {
26-
await Promise.all(runDestructors(this));
29+
await Promise.all(runDestructors(getRoot()!));
2730
this.rootNode.innerHTML = '';
2831
this.rootNode = null!;
2932
}

src/components/Fallback.gts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Component } from '@lifeart/gxt';
2+
3+
export default class Fallback extends Component {
4+
<template>
5+
<div class='inline-flex flex-col items-center'>
6+
<div
7+
class='w-28 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse mt-2 mb-1'
8+
></div>
9+
</div>
10+
</template>
11+
}

src/components/LoadMeAsync.gts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { context } from '@/utils/context';
2+
import { SUSPENSE_CONTEXT } from '@/utils/suspense';
3+
import { Component } from '@lifeart/gxt';
4+
5+
export default class LoadMeAsync extends Component<{
6+
Args: { name: string };
7+
}> {
8+
constructor() {
9+
// @ts-ignore
10+
super(...arguments);
11+
console.log('LoadMeAsync created');
12+
this.suspense?.start();
13+
}
14+
@context(SUSPENSE_CONTEXT) suspense!: {
15+
start: () => void;
16+
end: () => void;
17+
};
18+
loadData = (_: HTMLElement) => {
19+
setTimeout(() => {
20+
this.suspense?.end();
21+
console.log('Data loaded');
22+
}, 2000);
23+
};
24+
<template>
25+
{{log 'loadMeAsync rendered'}}
26+
<div {{this.loadData}} class='inline-flex flex-col items-center'>Async
27+
component "{{@name}}"</div>
28+
</template>
29+
}

src/components/pages/PageOne.gts

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import { Component, cell } from '@lifeart/gxt';
1+
import { cell } from '@lifeart/gxt';
22
import { Smile } from './page-one/Smile';
33
import { Table } from './page-one/Table.gts';
4+
import { Suspense, lazy } from '@/utils/suspense';
5+
import Fallback from '@/components/Fallback';
6+
7+
const LoadMeAsync = lazy(() => import('@/components/LoadMeAsync'));
48

59
function Controls() {
610
const color = cell('red');
@@ -28,7 +32,21 @@ export function PageOne() {
2832
<div class='text-white p-3'>
2933
<Controls />
3034
<br />
31-
35+
<Suspense @fallback={{Fallback}}>
36+
<LoadMeAsync @name='foo' />
37+
<Suspense @fallback={{Fallback}}>
38+
<LoadMeAsync @name='bar' />
39+
<Suspense @fallback={{Fallback}}>
40+
<LoadMeAsync @name='baz' />
41+
<Suspense @fallback={{Fallback}}>
42+
<LoadMeAsync @name='boo' />
43+
<Suspense @fallback={{Fallback}}>
44+
<LoadMeAsync @name='doo' />
45+
</Suspense>
46+
</Suspense>
47+
</Suspense>
48+
</Suspense>
49+
</Suspense>
3250
<div>Imagine a world where the robust, mature ecosystems of development
3351
tools meet the cutting-edge performance of modern compilers. That's what
3452
we're building here! Our platform takes the best of established

src/utils/benchmark/benchmark.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,35 @@ import { withRehydration } from '@/utils/ssr/rehydration';
33
import { getDocument } from '@/utils/dom-api';
44
import { measureRender } from '@/utils/benchmark/measure-render';
55
import { setResolveRender } from '@/utils/runtime';
6+
import { runDestructors } from '@/utils/component';
7+
import { getRoot, resetRoot } from '@/utils/dom';
68

79
export function createBenchmark() {
810
return {
911
async render() {
1012
await measureRender('render', 'renderStart', 'renderEnd', () => {
1113
const root = getDocument().getElementById('app')!;
14+
let appRef: Application | null = null;
1215
if (root.childNodes.length > 1) {
1316
try {
1417
// @ts-expect-error
1518
withRehydration(function () {
16-
return new Application(root);
19+
appRef = new Application(root);
20+
return appRef;
1721
}, root);
1822
console.info('Rehydration successful');
1923
} catch (e) {
20-
console.error('Rehydration failed, fallback to normal render', e);
21-
root.innerHTML = '';
22-
new Application(root);
24+
(async() => {
25+
console.error('Rehydration failed, fallback to normal render', e);
26+
await runDestructors(getRoot()!);
27+
resetRoot();
28+
root.innerHTML = '';
29+
appRef = new Application(root);
30+
})();
31+
2332
}
2433
} else {
25-
new Application(root);
34+
appRef = new Application(root);
2635
}
2736
});
2837

src/utils/context.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { registerDestructor } from './glimmer/destroyable';
22
import { Component } from './component';
3-
import { PARENT_GRAPH } from './shared';
4-
import { getRoot } from './dom';
3+
import { $args, PARENT_GRAPH } from './shared';
4+
import { $PARENT_SYMBOL, getRoot } from './dom';
55

66
const CONTEXTS = new WeakMap<Component<any>, Map<symbol, any>>();
77

@@ -13,7 +13,7 @@ export function context(contextKey: symbol): (klass: any, key: string, descripto
1313
) {
1414
return {
1515
get() {
16-
return getContext(this, contextKey) || getContext(getRoot()!, contextKey) || descriptor!.initializer?.call(this);
16+
return getContext(this, contextKey) || getContext(this[$args][$PARENT_SYMBOL], contextKey) || getContext(getRoot()!, contextKey) || descriptor!.initializer?.call(this);
1717
},
1818
}
1919
}

src/utils/control-flow/if.ts

+14-9
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
} from '@/utils/shared';
2323
import { opcodeFor } from '@/utils/vm';
2424

25+
export type IfFunction = () => boolean;
26+
2527
export class IfCondition {
2628
isDestructorRunning = false;
2729
prevComponent: GenericReturnType | null = null;
@@ -33,15 +35,15 @@ export class IfCondition {
3335
placeholder: Comment;
3436
throwedError: Error | null = null;
3537
destroyPromise: Promise<any> | null = null;
36-
trueBranch: (ifContext: Component<any>) => GenericReturnType;
37-
falseBranch: (ifContext: Component<any>) => GenericReturnType;
38+
trueBranch: (ifContext: IfCondition) => GenericReturnType;
39+
falseBranch: (ifContext: IfCondition) => GenericReturnType;
3840
constructor(
3941
parentContext: Component<any>,
40-
maybeCondition: Cell<boolean>,
42+
maybeCondition: Cell<boolean> | IfFunction | MergedCell,
4143
target: DocumentFragment | HTMLElement,
4244
placeholder: Comment,
43-
trueBranch: (ifContext: Component<any>) => GenericReturnType,
44-
falseBranch: (ifContext: Component<any>) => GenericReturnType,
45+
trueBranch: (ifContext: IfCondition) => GenericReturnType,
46+
falseBranch: (ifContext: IfCondition) => GenericReturnType,
4547
) {
4648
this.target = target;
4749
this.placeholder = placeholder;
@@ -111,7 +113,7 @@ export class IfCondition {
111113
this.renderBranch(nextBranch, this.runNumber);
112114
}
113115
renderBranch(
114-
nextBranch: (ifContext: Component<any>) => GenericReturnType,
116+
nextBranch: (ifContext: IfCondition) => GenericReturnType,
115117
runNumber: number,
116118
) {
117119
if (this.destroyPromise) {
@@ -162,11 +164,11 @@ export class IfCondition {
162164
this.destroyPromise = destroyElement(branch);
163165
await this.destroyPromise;
164166
}
165-
renderState(nextBranch: (ifContext: Component<any>) => GenericReturnType) {
167+
renderState(nextBranch: (ifContext: IfCondition) => GenericReturnType) {
166168
if (IS_DEV_MODE) {
167169
$DEBUG_REACTIVE_CONTEXTS.push(`if:${String(this.lastValue)}`);
168170
}
169-
this.prevComponent = nextBranch(this as unknown as Component<any>);
171+
this.prevComponent = nextBranch(this);
170172
if (IS_DEV_MODE) {
171173
$DEBUG_REACTIVE_CONTEXTS.pop();
172174
}
@@ -178,14 +180,17 @@ export class IfCondition {
178180
return;
179181
}
180182
async destroy() {
183+
if (this.isDestructorRunning) {
184+
throw new Error('Already destroying');
185+
}
181186
this.isDestructorRunning = true;
182187
if (this.placeholder.isConnected) {
183188
this.placeholder.parentNode!.removeChild(this.placeholder);
184189
}
185190
await this.destroyBranch();
186191
await Promise.all(this.destructors.map((destroyFn) => destroyFn()));
187192
}
188-
setupCondition(maybeCondition: Cell<boolean>) {
193+
setupCondition(maybeCondition: Cell<boolean> | IfFunction | MergedCell) {
189194
if (isFn(maybeCondition)) {
190195
this.condition = formula(
191196
() => {

src/utils/dom.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
} from './shared';
4646
import { isRehydrationScheduled } from './ssr/rehydration';
4747
import { createHotReload } from './hmr';
48-
import { IfCondition } from './control-flow/if';
48+
import { IfCondition, type IfFunction } from './control-flow/if';
4949

5050
type RenderableType = Node | ComponentReturnType | string | number;
5151
type ShadowRootMode = 'open' | 'closed' | null;
@@ -68,14 +68,15 @@ type Props = [TagProp[], TagAttr[], TagEvent[], FwType?];
6868

6969
type Fn = () => unknown;
7070
type InElementFnArg = () => HTMLElement;
71-
type BranchCb = () => ComponentReturnType | Node;
71+
type BranchCb = (ctx: IfCondition) => ComponentReturnType | Node | null;
7272

7373
// EMPTY DOM PROPS
7474
export const $_edp = [[], [], []] as Props;
7575
export const $_emptySlot = Object.seal(Object.freeze({}));
7676

7777
export const $SLOTS_SYMBOL = Symbol('slots');
7878
export const $PROPS_SYMBOL = Symbol('props');
79+
export const $PARENT_SYMBOL = Symbol('parent');
7980

8081
const $_className = 'className';
8182

@@ -539,7 +540,7 @@ export function $_inElement(
539540
export function $_ucw(
540541
roots: (context: Component<any>) => (Node | ComponentReturnType)[],
541542
ctx: any,
542-
) {
543+
): ComponentReturnType {
543544
return component(
544545
function UnstableChildWrapper(this: Component<any>) {
545546
if (IS_DEV_MODE) {
@@ -550,7 +551,7 @@ export function $_ucw(
550551
} as unknown as Component<any>,
551552
{},
552553
ctx,
553-
);
554+
) as ComponentReturnType;
554555
}
555556

556557
if (IS_DEV_MODE) {
@@ -712,6 +713,8 @@ function _component(
712713
comp = comp.value;
713714
}
714715
}
716+
// @ts-expect-error index type
717+
args[$PARENT_SYMBOL] = ctx;
715718
// @ts-expect-error construct signature
716719
let instance = comp.prototype === undefined ? comp(args, fw) : new (comp as unknown as Component<any>)(args, fw);
717720
if (isFn(instance)) {
@@ -927,8 +930,9 @@ function toNodeReturnType(outlet: HTMLElement | DocumentFragment, ctx: any = nul
927930
};
928931
}
929932

933+
930934
function ifCond(
931-
cell: Cell<boolean>,
935+
cell: Cell<boolean> | MergedCell | IfFunction,
932936
trueBranch: BranchCb,
933937
falseBranch: BranchCb,
934938
ctx: Component<any>,

src/utils/glimmer/destroyable.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,16 @@ export type Destructors = Array<DestructorFn>;
44
// destructorsForInstance
55
const $dfi: WeakMap<object, Destructors> = new WeakMap();
66
const destroyedObjects = new WeakSet<object>();
7-
7+
// const destroyStack = new WeakMap<object, any>();
88
export function destroy(ctx: object) {
9+
if (destroyedObjects.has(ctx)) {
10+
// console.info('Already destroyed', ctx.debugName || ctx.constructor.name);
11+
// console.warn(new Error().stack);
12+
// console.warn(destroyStack.get(ctx));
13+
return;
14+
}
915
destroyedObjects.add(ctx);
16+
// destroyStack.set(ctx, new Error().stack);
1017
if (!$dfi.has(ctx)) {
1118
return;
1219
}

0 commit comments

Comments
 (0)