From 8e1c80091eb216b16752c37054c4e18aacb7561f Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 9 Nov 2024 12:42:53 +0300 Subject: [PATCH 01/72] [feat]: nested rendering contexts --- src/utils/component.ts | 6 +++++- src/utils/control-flow/if.ts | 2 ++ src/utils/control-flow/list.ts | 2 ++ src/utils/dom-api.ts | 2 ++ src/utils/dom.ts | 25 ++++++++++++++++++++++++- src/utils/provider.ts | 28 ++++++++++++++++++++++++++++ src/utils/shared.ts | 3 +++ 7 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/utils/provider.ts diff --git a/src/utils/component.ts b/src/utils/component.ts index 0c6e1a85..6ee6e3d1 100644 --- a/src/utils/component.ts +++ b/src/utils/component.ts @@ -9,7 +9,7 @@ import type { Invoke, ComponentReturn, } from '@glint/template/-private/integration'; -import { api } from '@/utils/dom-api'; +import { api, RENDERING_CONTEXT } from '@/utils/dom-api'; import { isFn, $template, @@ -24,6 +24,7 @@ import { PARENT_GRAPH, } from './shared'; import { addChild, getRoot, setRoot } from './dom'; +import { provideContext } from './context'; export type ComponentRenderTarget = | HTMLElement @@ -109,6 +110,9 @@ export function renderComponent( } } + console.log('context provided', getRoot(), api); + provideContext(getRoot()!, RENDERING_CONTEXT, api); + if ($template in component && isFn(component[$template])) { return renderComponent( component[$template](), diff --git a/src/utils/control-flow/if.ts b/src/utils/control-flow/if.ts index 24a424d5..b4c435ec 100644 --- a/src/utils/control-flow/if.ts +++ b/src/utils/control-flow/if.ts @@ -19,6 +19,7 @@ import { isFn, isPrimitive, isTagLike, + addToTree } from '@/utils/shared'; import { opcodeFor } from '@/utils/vm'; @@ -48,6 +49,7 @@ export class IfCondition { this.setupCondition(maybeCondition); this.trueBranch = trueBranch; this.falseBranch = falseBranch; + addToTree(parentContext, this); this.destructors.push(opcodeFor(this.condition, this.syncState.bind(this))); associateDestroyable(parentContext, [this.destroy.bind(this)]); if (IS_DEV_MODE) { diff --git a/src/utils/control-flow/list.ts b/src/utils/control-flow/list.ts index d023a416..1ca1f645 100644 --- a/src/utils/control-flow/list.ts +++ b/src/utils/control-flow/list.ts @@ -19,6 +19,7 @@ import { isPrimitive, isTagLike, LISTS_FOR_HMR, + addToTree, } from '@/utils/shared'; import { isRehydrationScheduled } from '@/utils/ssr/rehydration'; @@ -98,6 +99,7 @@ export class BasicListComponent { topMarker: Comment, ) { this.ItemComponent = ItemComponent; + addToTree(ctx, this); this.parentCtx = ctx; const mainNode = outlet; this[$nodes] = []; diff --git a/src/utils/dom-api.ts b/src/utils/dom-api.ts index f0672498..4e19a727 100644 --- a/src/utils/dom-api.ts +++ b/src/utils/dom-api.ts @@ -1,6 +1,8 @@ import { getNodeCounter, incrementNodeCounter } from '@/utils/dom'; import { IN_SSR_ENV, noop } from './shared'; +export const RENDERING_CONTEXT = Symbol('RENDERING_CONTEXT'); + let $doc = typeof document !== 'undefined' ? document diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 2ad0b409..9f02b425 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -26,7 +26,7 @@ import { destroy, registerDestructor, } from './glimmer/destroyable'; -import { api, getDocument } from '@/utils/dom-api'; +import { api as HTMLAPI, getDocument, RENDERING_CONTEXT } from '@/utils/dom-api'; import { isFn, isPrimitive, @@ -47,6 +47,7 @@ import { isRehydrationScheduled } from './ssr/rehydration'; import { createHotReload } from './hmr'; import { IfCondition } from './control-flow/if'; import { CONSTANTS } from '../../plugins/symbols'; +import { getContext } from './context'; type RenderableType = Node | ComponentReturnType | string | number; type ShadowRootMode = 'open' | 'closed' | null; @@ -82,6 +83,7 @@ const $_className = 'className'; let unstableWrapperId: number = 0; let ROOT: Component | null = null; +let api = HTMLAPI; export const $_MANAGERS = { component: { @@ -494,6 +496,11 @@ function _DOM( ctx: any, ): Node { NODE_COUNTER++; + api = getContext(ctx, RENDERING_CONTEXT)!; + if (!api) { + debugger; + api = getContext(ctx, RENDERING_CONTEXT)!; + } const element = api.element(tag); if (IS_DEV_MODE) { $DEBUG_REACTIVE_CONTEXTS.push(`${tag}`); @@ -626,6 +633,7 @@ export function $_inElement( ) { return component( function UnstableChildWrapper(this: Component) { + $_GET_ARGS(this, arguments); if (IS_DEV_MODE) { // @ts-expect-error construct signature this.debugName = `InElement-${unstableWrapperId++}`; @@ -660,6 +668,9 @@ export function $_ucw( ) { return component( function UnstableChildWrapper(this: Component) { + // console.log(this, ...arguments); + $_GET_ARGS(this, arguments); + // debugger; if (IS_DEV_MODE) { // @ts-expect-error construct signature this.debugName = `UnstableChildWrapper-${unstableWrapperId++}`; @@ -837,6 +848,7 @@ function _component( fw: FwType, ctx: Component, ) { + args['_context'] = ctx; let comp = _comp; if (WITH_EMBER_INTEGRATION) { if ($_MANAGERS.component.canHandle(_comp)) { @@ -897,7 +909,10 @@ function _component( } if (instance.ctx !== null) { // for now we adding only components with context + // debugger; addToTree(ctx, instance.ctx); + // addToTree(ctx, instance); + if (IS_DEV_MODE) { setBounds(instance); } @@ -1163,6 +1178,13 @@ const ArgProxyHandler = { }; export function $_GET_ARGS(ctx: any, args: any) { ctx[$args] = ctx[$args] || args[0] || {}; + const parentContext = ctx[$args]['_context']; + if (parentContext) { + // console.log('context', parentContext, ctx); + addToTree(parentContext, ctx); + } else { + addToTree(getRoot()!, ctx); + } } export function $_GET_SLOTS(ctx: any, args: any) { return (args[0] || {})[$SLOTS_SYMBOL] || ctx[$args]?.[$SLOTS_SYMBOL] || {}; @@ -1238,6 +1260,7 @@ export function $_dc( const destructor = opcodeFor(_cmp, (value: any) => { if (typeof value !== 'function') { result = value; + addToTree(ctx, result); return; } if (value !== ref) { diff --git a/src/utils/provider.ts b/src/utils/provider.ts new file mode 100644 index 00000000..b1875691 --- /dev/null +++ b/src/utils/provider.ts @@ -0,0 +1,28 @@ +import { Component } from "./component"; +import { $template } from "./shared"; +import { provideContext } from './context'; +import { getDocument, RENDERING_CONTEXT } from "./dom-api"; + +// SVG DOM API +const svgDomApi = { + element: (tagName: string): SVGElement => { + return getDocument().createElementNS('http://www.w3.org/2000/svg', tagName) as SVGElement; + }, + setAttribute: (element: SVGElement, name: string, value: string) => { + element.setAttribute(name, value); + }, + append: (parent: SVGElement, child: SVGElement) => { + parent.appendChild(child); + }, +}; + +export class SvgProvider extends Component { + constructor() { + super(...arguments); + provideContext(this, RENDERING_CONTEXT, svgDomApi); + + } + [$template]() { + return []; + } +} \ No newline at end of file diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 37637641..09a7b652 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -102,6 +102,9 @@ export function addToTree( node: Component, debugName?: string, ) { + // if (node.toString() === '[object Object]') { + // debugger; + // } if (IS_DEV_MODE) { if ('nodeType' in node) { throw new Error('invalid node'); From 8a9c59d34317e5b49d4cec95ed10730b6d41a2d6 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 28 Dec 2024 17:03:54 +0300 Subject: [PATCH 02/72] fix some tests --- src/tests/integration/context-test.gts | 2 +- src/tests/integration/modifier-test.gts | 2 +- src/tests/utils.ts | 13 +++++++------ src/utils/component.ts | 8 ++++---- src/utils/context.ts | 12 ++++++------ src/utils/dom.ts | 17 ++++++++++++----- src/utils/shared.ts | 8 ++++++-- src/utils/ssr/rehydration-dom-api.ts | 5 +++++ src/utils/ssr/ssr.ts | 7 ++++++- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/src/tests/integration/context-test.gts b/src/tests/integration/context-test.gts index d91ac4d5..eba87b60 100644 --- a/src/tests/integration/context-test.gts +++ b/src/tests/integration/context-test.gts @@ -49,7 +49,7 @@ class ThemedButton extends Component { buttonClass: '', }; } - +// module('Integration | Context API', function () { test('context decorator falling back to root context', async function (assert) { await render( diff --git a/src/tests/integration/modifier-test.gts b/src/tests/integration/modifier-test.gts index 349f2445..5461d12f 100644 --- a/src/tests/integration/modifier-test.gts +++ b/src/tests/integration/modifier-test.gts @@ -25,7 +25,7 @@ module('Integration | Internal | modifier', function () { }); if (REACTIVE_MODIFIERS) { test('modifiers may be reactive', async function (assert) { - assert.expect(5); + assert.expect(6); const value = cell(3); let removeCount = 0; const modifier = (element: HTMLDivElement, v: number) => { diff --git a/src/tests/utils.ts b/src/tests/utils.ts index 3e5a3a73..a04f9037 100644 --- a/src/tests/utils.ts +++ b/src/tests/utils.ts @@ -1,7 +1,7 @@ import { type ComponentReturnType, destroyElementSync, renderComponent } from '@/utils/component'; import { getDocument } from '@/utils/dom-api'; import { withRehydration } from '@/utils/ssr/rehydration'; -import { getRoot, resetNodeCounter, setRoot, resetRoot } from '@/utils/dom'; +import { getRoot, resetNodeCounter, setRoot, resetRoot, createRoot } from '@/utils/dom'; import { renderInBrowser } from '@/utils/ssr/ssr'; import { runDestructors } from '@/utils/component'; import { registerDestructor } from '@/utils/glimmer/destroyable'; @@ -17,14 +17,14 @@ export async function cleanupRender() { export function rehydrate(component: ComponentReturnType) { let cmp: any = null; + let root = createRoot(); + setRoot(root); withRehydration(() => { // @ts-expect-error typings mismatch cmp = new component(); return cmp; }, renderTarget()); - if (!getRoot()) { - setRoot(cmp.ctx || cmp); - } + } export async function ssr(component: any) { if (getRoot()) { @@ -32,8 +32,10 @@ export async function ssr(component: any) { } resetNodeCounter(); let cmp: any = null; + let root = {}; + setRoot(root); const content = await renderInBrowser(() => { - cmp = new component({}); + cmp = new component(root); return cmp; }); renderTarget().innerHTML = content; @@ -42,7 +44,6 @@ export async function ssr(component: any) { } else { await Promise.all(runDestructors(cmp)); } - const root = getRoot(); if (root && cmp !== root) { await Promise.all(runDestructors(root)); } diff --git a/src/utils/component.ts b/src/utils/component.ts index 6ee6e3d1..ef3b3856 100644 --- a/src/utils/component.ts +++ b/src/utils/component.ts @@ -23,7 +23,7 @@ import { FRAGMENT_TYPE, PARENT_GRAPH, } from './shared'; -import { addChild, getRoot, setRoot } from './dom'; +import { addChild, createRoot, getRoot, Root, setRoot } from './dom'; import { provideContext } from './context'; export type ComponentRenderTarget = @@ -105,12 +105,12 @@ export function renderComponent( } } else { if (appRoot === null) { - setRoot(owner || component.ctx || (component as any)); + setRoot(createRoot()); } } } - console.log('context provided', getRoot(), api); + // console.log('context provided', getRoot(), api); provideContext(getRoot()!, RENDERING_CONTEXT, api); if ($template in component && isFn(component[$template])) { @@ -403,7 +403,7 @@ function runDestructorsSync(targetNode: Component) { } } export function runDestructors( - target: Component, + target: Component | Root, promises: Array> = [], ): Array> { destroy(target); diff --git a/src/utils/context.ts b/src/utils/context.ts index 99528e87..a38acb93 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -1,9 +1,9 @@ import { registerDestructor } from './glimmer/destroyable'; import { Component } from './component'; import { PARENT_GRAPH } from './shared'; -import { getRoot } from './dom'; +import { getRoot, Root } from './dom'; -const CONTEXTS = new WeakMap, Map>(); +const CONTEXTS = new WeakMap | Root, Map>(); export function context(contextKey: symbol): (klass: any, key: string, descriptor?: PropertyDescriptor & { initializer?: () => any } ) => void { return function contextDecorator( @@ -19,8 +19,8 @@ export function context(contextKey: symbol): (klass: any, key: string, descripto } }; -export function getContext(ctx: Component, key: symbol): T | null { - let current: Component | null = ctx; +export function getContext(ctx: Component | Root, key: symbol): T | null { + let current: Component | Root | null = ctx; while (current) { const context = CONTEXTS.get(current); if (context?.has(key)) { @@ -35,7 +35,7 @@ export function getContext(ctx: Component, key: symbol): T | null { } export function provideContext( - ctx: Component, + ctx: Component | Root, key: symbol, value: T, ): void { @@ -50,6 +50,6 @@ export function provideContext( }); } -function findParentComponent(component: Component): Component | null { +function findParentComponent(component: Component | Root): Component | Root | null { return PARENT_GRAPH.get(component)! ?? null; } diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 9f02b425..270ffe07 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -82,7 +82,8 @@ export const $PROPS_SYMBOL = Symbol('props'); const $_className = 'className'; let unstableWrapperId: number = 0; -let ROOT: Component | null = null; +export class Root {}; +let ROOT: Root | null = null; let api = HTMLAPI; export const $_MANAGERS = { @@ -214,11 +215,14 @@ export function $_helperHelper(params: any, hash: any) { throw new Error('Unable to use helper with non-ember helpers'); } } - +export function createRoot() { + const root = new Root(); + return root; +} export function resetRoot() { ROOT = null; } -export function setRoot(root: Component) { +export function setRoot(root: Root) { if (IS_DEV_MODE) { if (ROOT) { throw new Error('Root already exists'); @@ -496,11 +500,14 @@ function _DOM( ctx: any, ): Node { NODE_COUNTER++; + let oldAPI = api; api = getContext(ctx, RENDERING_CONTEXT)!; if (!api) { - debugger; api = getContext(ctx, RENDERING_CONTEXT)!; } + if (!api) { + api = oldAPI; + } const element = api.element(tag); if (IS_DEV_MODE) { $DEBUG_REACTIVE_CONTEXTS.push(`${tag}`); @@ -1075,7 +1082,7 @@ function text( function getRenderTargets(debugName: string) { const ifPlaceholder = IS_DEV_MODE ? api.comment(debugName) : api.comment(''); let outlet = isRehydrationScheduled() - ? ifPlaceholder.parentElement! + ? (ifPlaceholder.parentElement || api.fragment()) : api.fragment(); if (!ifPlaceholder.isConnected) { diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 09a7b652..a89b7ed8 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -6,6 +6,7 @@ import { } from '@/utils/component'; import { type AnyCell } from './reactive'; import { type BasicListComponent } from './control-flow/list'; +import { Root } from './dom'; export const isTag = Symbol('isTag'); export const $template = 'template' as const; @@ -51,7 +52,7 @@ export function isTagLike(child: unknown): child is AnyCell { } export const RENDER_TREE = new WeakMap, Set>(); -export const PARENT_GRAPH = new WeakMap, Component>(); +export const PARENT_GRAPH = new WeakMap | Root, Component>(); export const BOUNDS = new WeakMap< Component, Array @@ -118,7 +119,10 @@ export function addToTree( // @todo - case 42 associateDestroyable(node, [ () => { - const tree = RENDER_TREE.get(ctx)!; + const tree = RENDER_TREE.get(ctx); + if (tree === undefined) { + return; + } tree.delete(node); if (tree.size === 0) { RENDER_TREE.delete(ctx); diff --git a/src/utils/ssr/rehydration-dom-api.ts b/src/utils/ssr/rehydration-dom-api.ts index 5ca528dc..16bd2620 100644 --- a/src/utils/ssr/rehydration-dom-api.ts +++ b/src/utils/ssr/rehydration-dom-api.ts @@ -156,6 +156,11 @@ export const api = { targetIndex: number = 0, ) { if (isRehydrationScheduled()) { + if (import.meta.env.DEV) { + if (!parent) { + 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/ssr.ts b/src/utils/ssr/ssr.ts index 43b0be11..19662ddf 100644 --- a/src/utils/ssr/ssr.ts +++ b/src/utils/ssr/ssr.ts @@ -13,10 +13,15 @@ type EnvironmentParams = { export async function renderInBrowser( componentRenderFn: (rootNode: HTMLElement) => ComponentReturnType, ) { + if (import.meta.env.DEV) { + if (!getRoot()) { + throw new Error('Unable to detect render root'); + } + } const doc = getDocument(); const rootNode = doc.createElement('div'); // @todo - add destructor - renderComponent(componentRenderFn(rootNode), rootNode); + renderComponent(componentRenderFn(rootNode), rootNode, getRoot()); const html = rootNode.innerHTML; rootNode.remove(); return html; From 03a14e31e59d2350470d2629dc7ba4db7d7d2798 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 28 Dec 2024 17:14:41 +0300 Subject: [PATCH 03/72] more context tests --- src/tests/integration/context-test.gts | 115 ++++++++++++++++++++++++- src/utils/dom.ts | 5 ++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/tests/integration/context-test.gts b/src/tests/integration/context-test.gts index eba87b60..7e35722f 100644 --- a/src/tests/integration/context-test.gts +++ b/src/tests/integration/context-test.gts @@ -49,8 +49,121 @@ class ThemedButton extends Component { buttonClass: '', }; } -// + module('Integration | Context API', function () { + test('context is still available inside nested component', async function (assert) { + class Boo extends Component { + + } + await render( + , + ); + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); + test('context is still available in yield', async function (assert) { + class Boo extends Component { + + } + await render( + , + ); + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); + test('context is still available in in-element', async function (assert) { + let _node: HTMLDivElement; + function setNode(e: HTMLDivElement) { + _node = e; + } + function node() { + return _node; + } + await render( + , + ); + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); + test('context is still available in each', async function (assert) { + await render( + , + ); + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); + test('context is still available inside if [false branch]', async function (assert) { + await render( + , + ); + + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); + test('context is still available inside if [true branch]', async function (assert) { + await render( + , + ); + + assert + .dom('[data-test-button]') + .hasText('Fake', 'Button receives intl from root context'); + }); test('context decorator falling back to root context', async function (assert) { await render( , ); - const h1 = qs('h1'); - const q = qs('q'); + const h1Node = qs('h1'); + const qNode = qs('q'); await rehydrate(