From 33be79b405f94951ca0916a55fe97d5adc6f9e2a Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Thu, 27 Jun 2024 00:43:22 +0300 Subject: [PATCH] feature: suspense api --- src/components/Application.gts | 5 +- src/components/Fallback.gts | 11 ++ src/components/LoadMeAsync.gts | 29 ++++ src/components/pages/PageOne.gts | 22 ++- src/utils/benchmark/benchmark.ts | 19 ++- src/utils/context.ts | 30 +++- src/utils/control-flow/if.ts | 23 +-- src/utils/dom.ts | 14 +- src/utils/glimmer/destroyable.ts | 3 + src/utils/ssr/rehydration-dom-api.ts | 6 +- src/utils/ssr/rehydration.ts | 1 + src/utils/suspense.ts | 200 +++++++++++++++++++++++++++ 12 files changed, 333 insertions(+), 30 deletions(-) create mode 100644 src/components/Fallback.gts create mode 100644 src/components/LoadMeAsync.gts create mode 100644 src/utils/suspense.ts diff --git a/src/components/Application.gts b/src/components/Application.gts index db84864a..ba6a966d 100644 --- a/src/components/Application.gts +++ b/src/components/Application.gts @@ -3,6 +3,7 @@ import { runDestructors, Component, tracked, + getRoot, } from '@lifeart/gxt'; import { PageOne } from './pages/PageOne.gts'; import { PageTwo } from './pages/PageTwo.gts'; @@ -11,8 +12,10 @@ import { Benchmark } from './pages/Benchmark.gts'; import { NestedRouter } from './pages/NestedRouter.gts'; import { router } from './../services/router'; +let version = 0; export class Application extends Component { router = router; + version = version++; @tracked now = Date.now(); rootNode!: HTMLElement; @@ -23,7 +26,7 @@ export class Application extends Component { benchmark: Benchmark, }; async destroy() { - await Promise.all(runDestructors(this)); + await Promise.all(runDestructors(getRoot()!)); this.rootNode.innerHTML = ''; this.rootNode = null!; } diff --git a/src/components/Fallback.gts b/src/components/Fallback.gts new file mode 100644 index 00000000..ddd33e7c --- /dev/null +++ b/src/components/Fallback.gts @@ -0,0 +1,11 @@ +import { Component } from '@lifeart/gxt'; + +export default class Fallback extends Component { + +} diff --git a/src/components/LoadMeAsync.gts b/src/components/LoadMeAsync.gts new file mode 100644 index 00000000..b5ffc17c --- /dev/null +++ b/src/components/LoadMeAsync.gts @@ -0,0 +1,29 @@ +import { context } from '@/utils/context'; +import { SUSPENSE_CONTEXT } from '@/utils/suspense'; +import { Component } from '@lifeart/gxt'; + +export default class LoadMeAsync extends Component<{ + Args: { name: string }; +}> { + constructor() { + // @ts-ignore + super(...arguments); + console.log('LoadMeAsync created'); + this.suspense?.start(); + } + @context(SUSPENSE_CONTEXT) suspense!: { + start: () => void; + end: () => void; + }; + loadData = (_: HTMLElement) => { + setTimeout(() => { + this.suspense?.end(); + console.log('Data loaded'); + }, 2000); + }; + +} diff --git a/src/components/pages/PageOne.gts b/src/components/pages/PageOne.gts index e7dd3da2..204fd52f 100644 --- a/src/components/pages/PageOne.gts +++ b/src/components/pages/PageOne.gts @@ -1,6 +1,10 @@ -import { Component, cell } from '@lifeart/gxt'; +import { cell } from '@lifeart/gxt'; import { Smile } from './page-one/Smile'; import { Table } from './page-one/Table.gts'; +import { Suspense, lazy } from '@/utils/suspense'; +import Fallback from '@/components/Fallback'; + +const LoadMeAsync = lazy(() => import('@/components/LoadMeAsync')); function Controls() { const color = cell('red'); @@ -28,7 +32,21 @@ export function PageOne() {

- + + + + + + + + + + + + + + +
Imagine a world where the robust, mature ecosystems of development tools meet the cutting-edge performance of modern compilers. That's what we're building here! Our platform takes the best of established diff --git a/src/utils/benchmark/benchmark.ts b/src/utils/benchmark/benchmark.ts index f263d3dc..0634b4b2 100644 --- a/src/utils/benchmark/benchmark.ts +++ b/src/utils/benchmark/benchmark.ts @@ -3,26 +3,35 @@ import { withRehydration } from '@/utils/ssr/rehydration'; import { getDocument } from '@/utils/dom-api'; import { measureRender } from '@/utils/benchmark/measure-render'; import { setResolveRender } from '@/utils/runtime'; +import { runDestructors } from '@/utils/component'; +import { getRoot, resetRoot } from '@/utils/dom'; export function createBenchmark() { return { async render() { await measureRender('render', 'renderStart', 'renderEnd', () => { const root = getDocument().getElementById('app')!; + let appRef: Application | null = null; if (root.childNodes.length > 1) { try { // @ts-expect-error withRehydration(function () { - return new Application(root); + appRef = new Application(root); + return appRef; }, root); console.info('Rehydration successful'); } catch (e) { - console.error('Rehydration failed, fallback to normal render', e); - root.innerHTML = ''; - new Application(root); + (async() => { + console.error('Rehydration failed, fallback to normal render', e); + await runDestructors(getRoot()!); + resetRoot(); + root.innerHTML = ''; + appRef = new Application(root); + })(); + } } else { - new Application(root); + appRef = new Application(root); } }); diff --git a/src/utils/context.ts b/src/utils/context.ts index 99528e87..b3cec5f3 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -1,11 +1,25 @@ import { registerDestructor } from './glimmer/destroyable'; import { Component } from './component'; -import { PARENT_GRAPH } from './shared'; -import { getRoot } from './dom'; +import { $args, PARENT_GRAPH } from './shared'; +import { $PARENT_SYMBOL, getRoot } from './dom'; const CONTEXTS = new WeakMap, Map>(); -export function context(contextKey: symbol): (klass: any, key: string, descriptor?: PropertyDescriptor & { initializer?: () => any } ) => void { +export function getAnyContext(ctx: Component, key: symbol): T | null { + return ( + getContext(ctx, key) || + getContext(ctx[$args][$PARENT_SYMBOL], key) || + getContext(getRoot()!, key) + ); +} + +export function context( + contextKey: symbol, +): ( + klass: any, + key: string, + descriptor?: PropertyDescriptor & { initializer?: () => any }, +) => void { return function contextDecorator( _: any, __: string, @@ -13,11 +27,13 @@ export function context(contextKey: symbol): (klass: any, key: string, descripto ) { return { get() { - return getContext(this, contextKey) || getContext(getRoot()!, contextKey) || descriptor!.initializer?.call(this); + return ( + getAnyContext(this, contextKey) || descriptor!.initializer?.call(this) + ); }, - } - } -}; + }; + }; +} export function getContext(ctx: Component, key: symbol): T | null { let current: Component | null = ctx; diff --git a/src/utils/control-flow/if.ts b/src/utils/control-flow/if.ts index 24a424d5..ae8938a9 100644 --- a/src/utils/control-flow/if.ts +++ b/src/utils/control-flow/if.ts @@ -22,6 +22,8 @@ import { } from '@/utils/shared'; import { opcodeFor } from '@/utils/vm'; +export type IfFunction = () => boolean; + export class IfCondition { isDestructorRunning = false; prevComponent: GenericReturnType | null = null; @@ -33,15 +35,15 @@ export class IfCondition { placeholder: Comment; throwedError: Error | null = null; destroyPromise: Promise | null = null; - trueBranch: (ifContext: Component) => GenericReturnType; - falseBranch: (ifContext: Component) => GenericReturnType; + trueBranch: (ifContext: IfCondition) => GenericReturnType; + falseBranch: (ifContext: IfCondition) => GenericReturnType; constructor( parentContext: Component, - maybeCondition: Cell, + maybeCondition: Cell | IfFunction | MergedCell, target: DocumentFragment | HTMLElement, placeholder: Comment, - trueBranch: (ifContext: Component) => GenericReturnType, - falseBranch: (ifContext: Component) => GenericReturnType, + trueBranch: (ifContext: IfCondition) => GenericReturnType, + falseBranch: (ifContext: IfCondition) => GenericReturnType, ) { this.target = target; this.placeholder = placeholder; @@ -111,7 +113,7 @@ export class IfCondition { this.renderBranch(nextBranch, this.runNumber); } renderBranch( - nextBranch: (ifContext: Component) => GenericReturnType, + nextBranch: (ifContext: IfCondition) => GenericReturnType, runNumber: number, ) { if (this.destroyPromise) { @@ -162,11 +164,11 @@ export class IfCondition { this.destroyPromise = destroyElement(branch); await this.destroyPromise; } - renderState(nextBranch: (ifContext: Component) => GenericReturnType) { + renderState(nextBranch: (ifContext: IfCondition) => GenericReturnType) { if (IS_DEV_MODE) { $DEBUG_REACTIVE_CONTEXTS.push(`if:${String(this.lastValue)}`); } - this.prevComponent = nextBranch(this as unknown as Component); + this.prevComponent = nextBranch(this); if (IS_DEV_MODE) { $DEBUG_REACTIVE_CONTEXTS.pop(); } @@ -178,6 +180,9 @@ export class IfCondition { return; } async destroy() { + if (this.isDestructorRunning) { + throw new Error('Already destroying'); + } this.isDestructorRunning = true; if (this.placeholder.isConnected) { // should be handled on the top level @@ -186,7 +191,7 @@ export class IfCondition { await this.destroyBranch(); await Promise.all(this.destructors.map((destroyFn) => destroyFn())); } - setupCondition(maybeCondition: Cell) { + setupCondition(maybeCondition: Cell | IfFunction | MergedCell) { if (isFn(maybeCondition)) { this.condition = formula( () => { diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 2ad0b409..26f73318 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -45,8 +45,8 @@ import { } from './shared'; import { isRehydrationScheduled } from './ssr/rehydration'; import { createHotReload } from './hmr'; -import { IfCondition } from './control-flow/if'; import { CONSTANTS } from '../../plugins/symbols'; +import { IfCondition, type IfFunction } from './control-flow/if'; type RenderableType = Node | ComponentReturnType | string | number; type ShadowRootMode = 'open' | 'closed' | null; @@ -69,7 +69,7 @@ type Props = [TagProp[], TagAttr[], TagEvent[], FwType?]; type Fn = () => unknown; type InElementFnArg = () => HTMLElement; -type BranchCb = () => ComponentReturnType | Node; +type BranchCb = (ctx: IfCondition) => ComponentReturnType | Node | null; // EMPTY DOM PROPS export const $_edp = [[], [], []] as Props; @@ -77,6 +77,7 @@ export const $_emptySlot = Object.seal(Object.freeze({})); export const $SLOTS_SYMBOL = Symbol('slots'); export const $PROPS_SYMBOL = Symbol('props'); +export const $PARENT_SYMBOL = Symbol('parent'); const $_className = 'className'; @@ -657,7 +658,7 @@ export function $_inElement( export function $_ucw( roots: (context: Component) => (Node | ComponentReturnType)[], ctx: any, -) { +): ComponentReturnType { return component( function UnstableChildWrapper(this: Component) { if (IS_DEV_MODE) { @@ -668,7 +669,7 @@ export function $_ucw( } as unknown as Component, {}, ctx, - ); + ) as ComponentReturnType; } if (IS_DEV_MODE) { @@ -854,6 +855,8 @@ function _component( comp = comp.value; } } + // @ts-expect-error index type + args[$PARENT_SYMBOL] = ctx; let instance = // @ts-expect-error construct signature comp.prototype === undefined @@ -1086,8 +1089,9 @@ function toNodeReturnType( }; } + function ifCond( - cell: Cell, + cell: Cell | MergedCell | IfFunction, trueBranch: BranchCb, falseBranch: BranchCb, ctx: Component, diff --git a/src/utils/glimmer/destroyable.ts b/src/utils/glimmer/destroyable.ts index d28c9615..a99c2001 100644 --- a/src/utils/glimmer/destroyable.ts +++ b/src/utils/glimmer/destroyable.ts @@ -6,6 +6,9 @@ const $dfi: WeakMap = new WeakMap(); const destroyedObjects = new WeakSet(); export function destroy(ctx: object) { + if (destroyedObjects.has(ctx)) { + return; + } destroyedObjects.add(ctx); const destructors = $dfi.get(ctx); if (destructors === undefined) { diff --git a/src/utils/ssr/rehydration-dom-api.ts b/src/utils/ssr/rehydration-dom-api.ts index 5ca528dc..f68e5dca 100644 --- a/src/utils/ssr/rehydration-dom-api.ts +++ b/src/utils/ssr/rehydration-dom-api.ts @@ -1,4 +1,4 @@ -import { getNodeCounter, incrementNodeCounter } from '@/utils/dom'; +import { getNodeCounter, getRoot, incrementNodeCounter } from '@/utils/dom'; import { getDocument } from '@/utils/dom-api'; import { @@ -156,6 +156,10 @@ export const api = { targetIndex: number = 0, ) { if (isRehydrationScheduled()) { + if (!parent) { + console.log(getRoot()); + // debugger; + } // in this case likely child is a text node, and we don't need to append it, we need to prepend it const childNodes = Array.from(parent.childNodes); const maybeIndex = childNodes.indexOf(child as any); diff --git a/src/utils/ssr/rehydration.ts b/src/utils/ssr/rehydration.ts index 6181bb6b..af61a2d0 100644 --- a/src/utils/ssr/rehydration.ts +++ b/src/utils/ssr/rehydration.ts @@ -164,6 +164,7 @@ export function withRehydration( withRehydrationStack.length = 0; nodesMap.clear(); resetNodeCounter(); + console.log('rollbackDOMAPI'); rollbackDOMAPI(); throw e; } diff --git a/src/utils/suspense.ts b/src/utils/suspense.ts new file mode 100644 index 00000000..626e0476 --- /dev/null +++ b/src/utils/suspense.ts @@ -0,0 +1,200 @@ +import { + Component, + type ComponentReturnType, + renderComponent, +} from './component'; +import { context, getAnyContext, provideContext } from './context'; +import { + $_fin, + $_if, + $_c, + $_GET_SLOTS, + $_slot, + $_GET_ARGS, + $_ucw, + getRoot, +} from './dom'; +import { tracked } from './reactive'; +import { $nodes, $template } from './shared'; +import { isDestroyed } from './glimmer/destroyable'; +import { api } from './dom-api'; +import type { IfCondition } from './control-flow/if'; + +export const SUSPENSE_CONTEXT = Symbol('suspense'); + +let i = 0; + +type SuspenseContext = { + start: () => void; + end: () => void; +}; + +export function followPromise>(ctx: Component, promise: T): T { + getAnyContext(ctx, SUSPENSE_CONTEXT)?.start(); + promise.finally(() => { + Promise.resolve().then(() => { + getAnyContext(ctx, SUSPENSE_CONTEXT)?.end(); + }); + }); + return promise; +} + +export function lazy(factory: () => Promise<{ default: T }>) { + class LazyComponent extends Component { + constructor(params: any) { + super(params); + this.params = params; + // @ts-ignore args types + this[$template] = this._template; + i++; + // this.load(); + setTimeout(() => this.load(), 1000 * i + 500); + if (i === 3) { + i = 0; + } + } + params = {}; + @tracked state = { loading: true, component: null }; + get isLoading() { + return this.state.loading; + } + get contentComponent() { + return this.state.component as unknown as Component; + } + @context(SUSPENSE_CONTEXT) suspense?: SuspenseContext; + async load() { + const { default: component } = await factory(); + if (isDestroyed(this)) { + return; + } + // @ts-ignore component type + this.state = { loading: false, component }; + } + _template() { + Promise.resolve().then(() => { + this.suspense?.start(); + }); + const root = getRoot()!; + // @ts-expect-error + console.log(`lazy: ${root.version}`); + + // @ts-ignore + return $_fin( + [ + $_if( + // @ts-ignore this type + () => this.isLoading, + () => { + return null; + }, + // @ts-ignore this type + (c) => { + try { + if (isDestroyed(c)) { + debugger; + } + if (isDestroyed(root)) { + debugger; + } + return $_c( + this.contentComponent, + this.params, + c as unknown as Component, + ); + } finally { + this.suspense?.end(); + } + }, + this, + ), + ], + // @ts-ignore + this, + ); + } + } + return LazyComponent as unknown as Awaited< + ReturnType + >['default']; +} + +export class Suspense extends Component { + constructor() { + // @ts-ignore args types + super(...arguments); + // @ts-ignore this type + provideContext(this, SUSPENSE_CONTEXT, this); + // @ts-ignore args types + this[$template] = this._template; + } + @tracked pendingAmount = 0; + isReleased = false; + start() { + if (isDestroyed(this)) { + return; + } + if (this.isReleased) { + console.error('Suspense is already released'); + return; + } + this.pendingAmount++; + } + end() { + if (isDestroyed(this)) { + return; + } + if (this.isReleased) { + console.error('Suspense is already released'); + return; + } + this.pendingAmount--; + this.isReleased = this.pendingAmount === 0; + } + get fallback(): ComponentReturnType { + return this.args.fallback; + } + _template() { + $_GET_ARGS(this, arguments); + const $slots = $_GET_SLOTS(this, arguments); + let trueBranch: null | ComponentReturnType = null; + let fragment = api.fragment(); + const root = getRoot()!; + // @ts-expect-error + console.log(`Suspense: ${root.version}`); + + return $_fin( + [ + $_if( + () => this.pendingAmount === 0, + (c: IfCondition) => { + if (trueBranch === null) { + trueBranch = $_ucw((c) => { + if (isDestroyed(c)) { + debugger; + } + if (isDestroyed(root)) { + debugger; + } + return ( + $_slot('default', () => [], $slots, c) as ComponentReturnType + )[$nodes]; + }, c); + renderComponent(trueBranch, fragment, c, true); + return $_c(this.fallback, {}, c as unknown as Component); + } else { + return { + ctx: trueBranch.ctx, + nodes: Array.from(fragment.childNodes), + }; + } + }, + (c: IfCondition) => { + return $_c(this.fallback, {}, c as unknown as Component); + }, + this, + ), + ], + this, + ); + } +}